feat(api): implement personalities CRUD API with DTOs and Prisma model
All checks were successful
ci/woodpecker/push/api Pipeline was successful

- Add GET /api/personalities?isActive=true|false (list with optional filter)
- Add GET /api/personalities/default (default personality endpoint)
- Add GET /api/personalities/:id (single personality by ID)
- Add POST /api/personalities (create personality)
- Add PATCH /api/personalities/:id (update personality)
- Add DELETE /api/personalities/:id (delete personality)
- Add POST /api/personalities/:id/set-default (convenience set-default)
- Add tone and formalityLevel fields to Prisma Personality model
- Add migration 20260227000000_add_personality_tone_formality
- Map Prisma field names to frontend API contract (systemPrompt->systemPromptTemplate, isEnabled->isActive)
- Apply WorkspaceGuard + PermissionGuard per project patterns
- Return { success: true, data } wrapper for list endpoint
- Add PersonalityQueryDto for isActive filter support
- Update spec files to reflect new field mapping and response shape

Resolves frontend 404 on /api/personalities

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 04:36:55 -06:00
parent dd0568cf15
commit fe5bc4c16a
12 changed files with 444 additions and 358 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable: add tone and formality_level columns to personalities
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';

View File

@@ -1068,6 +1068,10 @@ model Personality {
displayName String @map("display_name") displayName String @map("display_name")
description String? @db.Text description String? @db.Text
// Tone and formality
tone String @default("neutral")
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
// System prompt // System prompt
systemPrompt String @map("system_prompt") @db.Text systemPrompt String @map("system_prompt") @db.Text

View File

@@ -41,6 +41,7 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module"; import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module"; import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module"; import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({ @Module({
@@ -105,6 +106,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
SpeechModule, SpeechModule,
DashboardModule, DashboardModule,
TerminalModule, TerminalModule,
PersonalitiesModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -1,59 +1,38 @@
import { import { FormalityLevel } from "@prisma/client";
IsString, import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/** /**
* DTO for creating a new personality/assistant configuration * DTO for creating a new personality
* Field names match the frontend API contract from @mosaic/shared Personality type.
*/ */
export class CreatePersonalityDto { export class CreatePersonalityDto {
@IsString() @IsString({ message: "name must be a string" })
@MinLength(1) @MinLength(1, { message: "name must not be empty" })
@MaxLength(100) @MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string; // unique identifier slug name!: string;
@IsString()
@MinLength(1)
@MaxLength(200)
displayName!: string; // human-readable name
@IsOptional() @IsOptional()
@IsString() @IsString({ message: "description must be a string" })
@MaxLength(1000) @MaxLength(2000, { message: "description must not exceed 2000 characters" })
description?: string; description?: string;
@IsString() @IsString({ message: "tone must be a string" })
@MinLength(10) @MinLength(1, { message: "tone must not be empty" })
systemPrompt!: string; @MaxLength(100, { message: "tone must not exceed 100 characters" })
tone!: string;
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
formalityLevel!: FormalityLevel;
@IsString({ message: "systemPromptTemplate must be a string" })
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
systemPromptTemplate!: string;
@IsOptional() @IsOptional()
@IsNumber() @IsBoolean({ message: "isDefault must be a boolean" })
@Min(0)
@Max(2)
temperature?: number; // null = use provider default
@IsOptional()
@IsInt()
@Min(1)
maxTokens?: number; // null = use provider default
@IsOptional()
@IsUUID("4")
llmProviderInstanceId?: string; // FK to LlmProviderInstance
@IsOptional()
@IsBoolean()
isDefault?: boolean; isDefault?: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean({ message: "isActive must be a boolean" })
isEnabled?: boolean; isActive?: boolean;
} }

View File

@@ -1,2 +1,3 @@
export * from "./create-personality.dto"; export * from "./create-personality.dto";
export * from "./update-personality.dto"; export * from "./update-personality.dto";
export * from "./personality-query.dto";

View File

@@ -0,0 +1,12 @@
import { IsBoolean, IsOptional } from "class-validator";
import { Transform } from "class-transformer";
/**
* DTO for querying/filtering personalities
*/
export class PersonalityQueryDto {
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
@Transform(({ value }) => value === "true" || value === true)
isActive?: boolean;
}

View File

@@ -1,62 +1,42 @@
import { import { FormalityLevel } from "@prisma/client";
IsString, import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/** /**
* DTO for updating an existing personality/assistant configuration * DTO for updating an existing personality
* All fields are optional; only provided fields are updated.
*/ */
export class UpdatePersonalityDto { export class UpdatePersonalityDto {
@IsOptional() @IsOptional()
@IsString() @IsString({ message: "name must be a string" })
@MinLength(1) @MinLength(1, { message: "name must not be empty" })
@MaxLength(100) @MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string; // unique identifier slug name?: string;
@IsOptional() @IsOptional()
@IsString() @IsString({ message: "description must be a string" })
@MinLength(1) @MaxLength(2000, { message: "description must not exceed 2000 characters" })
@MaxLength(200)
displayName?: string; // human-readable name
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsString() @IsString({ message: "tone must be a string" })
@MinLength(10) @MinLength(1, { message: "tone must not be empty" })
systemPrompt?: string; @MaxLength(100, { message: "tone must not exceed 100 characters" })
tone?: string;
@IsOptional() @IsOptional()
@IsNumber() @IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
@Min(0) formalityLevel?: FormalityLevel;
@Max(2)
temperature?: number; // null = use provider default
@IsOptional() @IsOptional()
@IsInt() @IsString({ message: "systemPromptTemplate must be a string" })
@Min(1) @MinLength(1, { message: "systemPromptTemplate must not be empty" })
maxTokens?: number; // null = use provider default systemPromptTemplate?: string;
@IsOptional() @IsOptional()
@IsUUID("4") @IsBoolean({ message: "isDefault must be a boolean" })
llmProviderInstanceId?: string; // FK to LlmProviderInstance
@IsOptional()
@IsBoolean()
isDefault?: boolean; isDefault?: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean({ message: "isActive must be a boolean" })
isEnabled?: boolean; isActive?: boolean;
} }

View File

@@ -1,20 +1,24 @@
import type { Personality as PrismaPersonality } from "@prisma/client"; import type { FormalityLevel } from "@prisma/client";
/** /**
* Personality entity representing an assistant configuration * Personality response entity
* Maps Prisma Personality fields to the frontend API contract.
*
* Field mapping (Prisma -> API):
* systemPrompt -> systemPromptTemplate
* isEnabled -> isActive
* (tone, formalityLevel are identical in both)
*/ */
export class Personality implements PrismaPersonality { export interface PersonalityResponse {
id!: string; id: string;
workspaceId!: string; workspaceId: string;
name!: string; // unique identifier slug name: string;
displayName!: string; // human-readable name description: string | null;
description!: string | null; tone: string;
systemPrompt!: string; formalityLevel: FormalityLevel;
temperature!: number | null; // null = use provider default systemPromptTemplate: string;
maxTokens!: number | null; // null = use provider default isDefault: boolean;
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance isActive: boolean;
isDefault!: boolean; createdAt: Date;
isEnabled!: boolean; updatedAt: Date;
createdAt!: Date;
updatedAt!: Date;
} }

View File

@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesController } from "./personalities.controller";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import type { CreatePersonalityDto } from "./dto/create-personality.dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesController", () => { describe("PersonalitiesController", () => {
let controller: PersonalitiesController; let controller: PersonalitiesController;
let service: PersonalitiesService; let service: PersonalitiesService;
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
/** API response shape (frontend field names) */
const mockPersonality = { const mockPersonality = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "professional-assistant", name: "professional-assistant",
displayName: "Professional Assistant",
description: "A professional communication assistant", description: "A professional communication assistant",
systemPrompt: "You are a professional assistant who helps with tasks.", tone: "professional",
temperature: 0.7, formalityLevel: FormalityLevel.FORMAL,
maxTokens: 2000, systemPromptTemplate: "You are a professional assistant who helps with tasks.",
llmProviderInstanceId: "provider-123",
isDefault: true, isDefault: true,
isEnabled: true, isActive: true,
createdAt: new Date(), createdAt: new Date("2026-01-01"),
updatedAt: new Date(), updatedAt: new Date("2026-01-01"),
};
const mockRequest = {
user: { id: mockUserId },
workspaceId: mockWorkspaceId,
}; };
const mockPersonalitiesService = { const mockPersonalitiesService = {
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
}) })
.overrideGuard(AuthGuard) .overrideGuard(AuthGuard)
.useValue({ canActivate: () => true }) .useValue({ canActivate: () => true })
.overrideGuard(WorkspaceGuard)
.useValue({
canActivate: (ctx: {
switchToHttp: () => { getRequest: () => { workspaceId: string } };
}) => {
const req = ctx.switchToHttp().getRequest();
req.workspaceId = mockWorkspaceId;
return true;
},
})
.overrideGuard(PermissionGuard)
.useValue({ canActivate: () => true })
.compile(); .compile();
controller = module.get<PersonalitiesController>(PersonalitiesController); controller = module.get<PersonalitiesController>(PersonalitiesController);
service = module.get<PersonalitiesService>(PersonalitiesService); service = module.get<PersonalitiesService>(PersonalitiesService);
// Reset mocks
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return all personalities", async () => { it("should return success response with personalities list", async () => {
const mockPersonalities = [mockPersonality]; const mockList = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities); mockPersonalitiesService.findAll.mockResolvedValue(mockList);
const result = await controller.findAll(mockRequest); const result = await controller.findAll(mockWorkspaceId, {});
expect(result).toEqual(mockPersonalities); expect(result).toEqual({ success: true, data: mockList });
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId); expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
});
}); });
describe("findOne", () => { it("should pass isActive query filter to service", async () => {
it("should return a personality by id", async () => { mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
const result = await controller.findOne(mockRequest, mockPersonalityId); await controller.findAll(mockWorkspaceId, { isActive: true });
expect(result).toEqual(mockPersonality); expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
});
});
describe("findByName", () => {
it("should return a personality by name", async () => {
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
const result = await controller.findByName(mockRequest, "professional-assistant");
expect(result).toEqual(mockPersonality);
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
}); });
}); });
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
it("should return the default personality", async () => { it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
const result = await controller.findDefault(mockRequest); const result = await controller.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality); expect(result).toEqual(mockPersonality);
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId); expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
}); });
}); });
describe("findOne", () => {
it("should return a personality by id", async () => {
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality);
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
});
});
describe("create", () => { describe("create", () => {
it("should create a new personality", async () => { it("should create a new personality", async () => {
const createDto: CreatePersonalityDto = { const createDto: CreatePersonalityDto = {
name: "casual-helper", name: "casual-helper",
displayName: "Casual Helper",
description: "A casual helper", description: "A casual helper",
systemPrompt: "You are a casual assistant.", tone: "casual",
temperature: 0.8, formalityLevel: FormalityLevel.CASUAL,
maxTokens: 1500, systemPromptTemplate: "You are a casual assistant.",
}; };
mockPersonalitiesService.create.mockResolvedValue({ const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
...mockPersonality, mockPersonalitiesService.create.mockResolvedValue(created);
...createDto,
});
const result = await controller.create(mockRequest, createDto); const result = await controller.create(mockWorkspaceId, createDto);
expect(result).toMatchObject(createDto); expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
}); });
}); });
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
it("should update a personality", async () => { it("should update a personality", async () => {
const updateDto: UpdatePersonalityDto = { const updateDto: UpdatePersonalityDto = {
description: "Updated description", description: "Updated description",
temperature: 0.9, tone: "enthusiastic",
}; };
mockPersonalitiesService.update.mockResolvedValue({ const updated = { ...mockPersonality, ...updateDto };
...mockPersonality, mockPersonalitiesService.update.mockResolvedValue(updated);
...updateDto,
});
const result = await controller.update(mockRequest, mockPersonalityId, updateDto); const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto); expect(result).toMatchObject(updateDto);
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
it("should delete a personality", async () => { it("should delete a personality", async () => {
mockPersonalitiesService.delete.mockResolvedValue(undefined); mockPersonalitiesService.delete.mockResolvedValue(undefined);
await controller.delete(mockRequest, mockPersonalityId); await controller.delete(mockWorkspaceId, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
}); });
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
describe("setDefault", () => { describe("setDefault", () => {
it("should set a personality as default", async () => { it("should set a personality as default", async () => {
mockPersonalitiesService.setDefault.mockResolvedValue({ const updated = { ...mockPersonality, isDefault: true };
...mockPersonality, mockPersonalitiesService.setDefault.mockResolvedValue(updated);
isDefault: true,
});
const result = await controller.setDefault(mockRequest, mockPersonalityId); const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true }); expect(result).toMatchObject({ isDefault: true });
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);

View File

@@ -6,105 +6,122 @@ import {
Delete, Delete,
Body, Body,
Param, Param,
Query,
UseGuards, UseGuards,
Req,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { CreatePersonalityDto } from "./dto/create-personality.dto";
import { Personality } from "./entities/personality.entity"; import { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { PersonalityQueryDto } from "./dto/personality-query.dto";
interface AuthenticatedRequest { import type { PersonalityResponse } from "./entities/personality.entity";
user: { id: string };
workspaceId: string;
}
/** /**
* Controller for managing personality/assistant configurations * Controller for personality CRUD endpoints.
* Route: /api/personalities
*
* Guards applied in order:
* 1. AuthGuard - verifies the user is authenticated
* 2. WorkspaceGuard - validates workspace access
* 3. PermissionGuard - checks role-based permissions
*/ */
@Controller("personality") @Controller("personalities")
@UseGuards(AuthGuard) @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class PersonalitiesController { export class PersonalitiesController {
constructor(private readonly personalitiesService: PersonalitiesService) {} constructor(private readonly personalitiesService: PersonalitiesService) {}
/** /**
* List all personalities for the workspace * GET /api/personalities
* List all personalities for the workspace.
* Supports ?isActive=true|false filter.
*/ */
@Get() @Get()
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> { @RequirePermission(Permission.WORKSPACE_ANY)
return this.personalitiesService.findAll(req.workspaceId); async findAll(
@Workspace() workspaceId: string,
@Query() query: PersonalityQueryDto
): Promise<{ success: true; data: PersonalityResponse[] }> {
const data = await this.personalitiesService.findAll(workspaceId, query);
return { success: true, data };
} }
/** /**
* Get the default personality for the workspace * GET /api/personalities/default
* Get the default personality for the workspace.
* Must be declared before :id to avoid route conflicts.
*/ */
@Get("default") @Get("default")
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> { @RequirePermission(Permission.WORKSPACE_ANY)
return this.personalitiesService.findDefault(req.workspaceId); async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
return this.personalitiesService.findDefault(workspaceId);
} }
/** /**
* Get a personality by its unique name * GET /api/personalities/:id
*/ * Get a single personality by ID.
@Get("by-name/:name")
async findByName(
@Req() req: AuthenticatedRequest,
@Param("name") name: string
): Promise<Personality> {
return this.personalitiesService.findByName(req.workspaceId, name);
}
/**
* Get a personality by ID
*/ */
@Get(":id") @Get(":id")
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> { @RequirePermission(Permission.WORKSPACE_ANY)
return this.personalitiesService.findOne(req.workspaceId, id); async findOne(
@Workspace() workspaceId: string,
@Param("id") id: string
): Promise<PersonalityResponse> {
return this.personalitiesService.findOne(workspaceId, id);
} }
/** /**
* Create a new personality * POST /api/personalities
* Create a new personality.
*/ */
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create( async create(
@Req() req: AuthenticatedRequest, @Workspace() workspaceId: string,
@Body() dto: CreatePersonalityDto @Body() dto: CreatePersonalityDto
): Promise<Personality> { ): Promise<PersonalityResponse> {
return this.personalitiesService.create(req.workspaceId, dto); return this.personalitiesService.create(workspaceId, dto);
} }
/** /**
* Update a personality * PATCH /api/personalities/:id
* Update an existing personality.
*/ */
@Patch(":id") @Patch(":id")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async update( async update(
@Req() req: AuthenticatedRequest, @Workspace() workspaceId: string,
@Param("id") id: string, @Param("id") id: string,
@Body() dto: UpdatePersonalityDto @Body() dto: UpdatePersonalityDto
): Promise<Personality> { ): Promise<PersonalityResponse> {
return this.personalitiesService.update(req.workspaceId, id, dto); return this.personalitiesService.update(workspaceId, id, dto);
} }
/** /**
* Delete a personality * DELETE /api/personalities/:id
* Delete a personality.
*/ */
@Delete(":id") @Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> { @RequirePermission(Permission.WORKSPACE_MEMBER)
return this.personalitiesService.delete(req.workspaceId, id); async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
return this.personalitiesService.delete(workspaceId, id);
} }
/** /**
* Set a personality as the default * POST /api/personalities/:id/set-default
* Convenience endpoint to set a personality as the default.
*/ */
@Post(":id/set-default") @Post(":id/set-default")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async setDefault( async setDefault(
@Req() req: AuthenticatedRequest, @Workspace() workspaceId: string,
@Param("id") id: string @Param("id") id: string
): Promise<Personality> { ): Promise<PersonalityResponse> {
return this.personalitiesService.setDefault(req.workspaceId, id); return this.personalitiesService.setDefault(workspaceId, id);
} }
} }

View File

@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import type { CreatePersonalityDto } from "./dto/create-personality.dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { NotFoundException, ConflictException } from "@nestjs/common"; import { NotFoundException, ConflictException } from "@nestjs/common";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesService", () => { describe("PersonalitiesService", () => {
let service: PersonalitiesService; let service: PersonalitiesService;
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
const mockPersonality = { /** Raw Prisma record shape (uses Prisma field names) */
const mockPrismaRecord = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "professional-assistant", name: "professional-assistant",
displayName: "Professional Assistant", displayName: "Professional Assistant",
description: "A professional communication assistant", description: "A professional communication assistant",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPrompt: "You are a professional assistant who helps with tasks.", systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7, temperature: 0.7,
maxTokens: 2000, maxTokens: 2000,
llmProviderInstanceId: mockProviderId, llmProviderInstanceId: "provider-123",
isDefault: true, isDefault: true,
isEnabled: true, isEnabled: true,
createdAt: new Date(), createdAt: new Date("2026-01-01"),
updatedAt: new Date(), updatedAt: new Date("2026-01-01"),
};
/** Expected API response shape (uses frontend field names) */
const mockResponse = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
description: "A professional communication assistant",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
isDefault: true,
isActive: true,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
}; };
const mockPrismaService = { const mockPrismaService = {
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
count: vi.fn(),
}, },
$transaction: vi.fn((callback) => callback(mockPrismaService)),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
service = module.get<PersonalitiesService>(PersonalitiesService); service = module.get<PersonalitiesService>(PersonalitiesService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
// Reset mocks
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("create", () => { describe("create", () => {
const createDto: CreatePersonalityDto = { const createDto: CreatePersonalityDto = {
name: "casual-helper", name: "casual-helper",
displayName: "Casual Helper",
description: "A casual communication helper", description: "A casual communication helper",
systemPrompt: "You are a casual assistant.", tone: "casual",
temperature: 0.8, formalityLevel: FormalityLevel.CASUAL,
maxTokens: 1500, systemPromptTemplate: "You are a casual assistant.",
llmProviderInstanceId: mockProviderId, isDefault: false,
isActive: true,
}; };
it("should create a new personality", async () => { const createdRecord = {
mockPrismaService.personality.findFirst.mockResolvedValue(null); ...mockPrismaRecord,
mockPrismaService.personality.create.mockResolvedValue({ name: createDto.name,
...mockPersonality, description: createDto.description,
...createDto, tone: createDto.tone,
id: "new-personality-id", formalityLevel: createDto.formalityLevel,
systemPrompt: createDto.systemPromptTemplate,
isDefault: false, isDefault: false,
isEnabled: true, isEnabled: true,
}); id: "new-personality-id",
};
it("should create a new personality and return API response shape", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
const result = await service.create(mockWorkspaceId, createDto); const result = await service.create(mockWorkspaceId, createDto);
expect(result).toMatchObject(createDto); expect(result.name).toBe(createDto.name);
expect(result.tone).toBe(createDto.tone);
expect(result.formalityLevel).toBe(createDto.formalityLevel);
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
expect(result.isActive).toBe(true);
expect(result.isDefault).toBe(false);
expect(prisma.personality.create).toHaveBeenCalledWith({ expect(prisma.personality.create).toHaveBeenCalledWith({
data: { data: {
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: createDto.name, name: createDto.name,
displayName: createDto.displayName, displayName: createDto.name,
description: createDto.description ?? null, description: createDto.description ?? null,
systemPrompt: createDto.systemPrompt, tone: createDto.tone,
temperature: createDto.temperature ?? null, formalityLevel: createDto.formalityLevel,
maxTokens: createDto.maxTokens ?? null, systemPrompt: createDto.systemPromptTemplate,
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
isDefault: false, isDefault: false,
isEnabled: true, isEnabled: true,
}, },
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
}); });
it("should throw ConflictException when name already exists", async () => { it("should throw ConflictException when name already exists", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException); await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
}); });
it("should unset other defaults when creating a new default personality", async () => { it("should unset other defaults when creating a new default personality", async () => {
const createDefaultDto = { ...createDto, isDefault: true }; const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
// First call to findFirst checks for name conflict (should be null) const otherDefault = { ...mockPrismaRecord, id: "other-id" };
// Second call to findFirst finds the existing default personality
mockPrismaService.personality.findFirst mockPrismaService.personality.findFirst
.mockResolvedValueOnce(null) // No name conflict .mockResolvedValueOnce(null) // name conflict check
.mockResolvedValueOnce(mockPersonality); // Existing default .mockResolvedValueOnce(otherDefault); // existing default lookup
mockPrismaService.personality.update.mockResolvedValue({ mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
...mockPersonality,
isDefault: false,
});
mockPrismaService.personality.create.mockResolvedValue({ mockPrismaService.personality.create.mockResolvedValue({
...mockPersonality, ...createdRecord,
...createDefaultDto, isDefault: true,
}); });
await service.create(mockWorkspaceId, createDefaultDto); await service.create(mockWorkspaceId, createDefaultDto);
expect(prisma.personality.update).toHaveBeenCalledWith({ expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId }, where: { id: "other-id" },
data: { isDefault: false }, data: { isDefault: false },
}); });
}); });
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return all personalities for a workspace", async () => { it("should return mapped response list for a workspace", async () => {
const mockPersonalities = [mockPersonality]; mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
const result = await service.findAll(mockWorkspaceId); const result = await service.findAll(mockWorkspaceId);
expect(result).toEqual(mockPersonalities); expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockResponse);
expect(prisma.personality.findMany).toHaveBeenCalledWith({ expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId }, where: { workspaceId: mockWorkspaceId },
orderBy: [{ isDefault: "desc" }, { name: "asc" }], orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}); });
}); });
it("should filter by isActive when provided", async () => {
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
await service.findAll(mockWorkspaceId, { isActive: true });
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isEnabled: true },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
});
}); });
describe("findOne", () => { describe("findOne", () => {
it("should return a personality by id", async () => { it("should return a mapped personality response by id", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId); const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality); expect(result).toEqual(mockResponse);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({ expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
}); });
}); });
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
}); });
describe("findByName", () => { describe("findByName", () => {
it("should return a personality by name", async () => { it("should return a mapped personality response by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findByName(mockWorkspaceId, "professional-assistant"); const result = await service.findByName(mockWorkspaceId, "professional-assistant");
expect(result).toEqual(mockPersonality); expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({ expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
workspaceId: mockWorkspaceId,
name: "professional-assistant",
},
}); });
}); });
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
describe("findDefault", () => { describe("findDefault", () => {
it("should return the default personality", async () => { it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findDefault(mockWorkspaceId); const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality); expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({ expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true }, where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
}); });
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
describe("update", () => { describe("update", () => {
const updateDto: UpdatePersonalityDto = { const updateDto: UpdatePersonalityDto = {
description: "Updated description", description: "Updated description",
temperature: 0.9, tone: "formal",
isActive: false,
}; };
it("should update a personality", async () => { it("should update a personality and return mapped response", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); const updatedRecord = {
mockPrismaService.personality.findFirst.mockResolvedValue(null); ...mockPrismaRecord,
mockPrismaService.personality.update.mockResolvedValue({ description: updateDto.description,
...mockPersonality, tone: updateDto.tone,
...updateDto, isEnabled: false,
}); };
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto); const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto); expect(result.description).toBe(updateDto.description);
expect(prisma.personality.update).toHaveBeenCalledWith({ expect(result.tone).toBe(updateDto.tone);
where: { id: mockPersonalityId }, expect(result.isActive).toBe(false);
data: updateDto,
});
}); });
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow( await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
NotFoundException NotFoundException
); );
}); });
it("should throw ConflictException when updating to existing name", async () => { it("should throw ConflictException when updating to an existing name", async () => {
const updateNameDto = { name: "existing-name" }; const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
mockPrismaService.personality.findFirst.mockResolvedValue({
...mockPersonality, mockPrismaService.personality.findFirst
id: "different-id", .mockResolvedValueOnce(mockPrismaRecord) // findOne check
}); .mockResolvedValueOnce(conflictRecord); // name conflict
await expect( await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto) service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
}); });
it("should unset other defaults when setting as default", async () => { it("should unset other defaults when setting as default", async () => {
const updateDefaultDto = { isDefault: true }; const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults .mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default .mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default .mockResolvedValueOnce(updatedRecord);
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto); await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
where: { id: "other-id" }, where: { id: "other-id" },
data: { isDefault: false }, data: { isDefault: false },
}); });
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId },
data: updateDefaultDto,
});
}); });
}); });
describe("delete", () => { describe("delete", () => {
it("should delete a personality", async () => { it("should delete a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.delete.mockResolvedValue(undefined); mockPrismaService.personality.delete.mockResolvedValue(undefined);
await service.delete(mockWorkspaceId, mockPersonalityId); await service.delete(mockWorkspaceId, mockPersonalityId);
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
}); });
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
describe("setDefault", () => { describe("setDefault", () => {
it("should set a personality as default", async () => { it("should set a personality as default", async () => {
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true }; const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedPersonality = { ...mockPersonality, isDefault: true }; const updatedRecord = { ...mockPrismaRecord, isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality); mockPrismaService.personality.findFirst
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); .mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default .mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce(updatedPersonality); // Set new default .mockResolvedValueOnce(updatedRecord);
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId); const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true }); expect(result.isDefault).toBe(true);
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, { expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId }, where: { id: mockPersonalityId },
data: { isDefault: true }, data: { isDefault: true },
}); });
}); });
it("should throw NotFoundException when personality not found", async () => { it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null); mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow( await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException

View File

@@ -1,10 +1,17 @@
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common"; import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import type { CreatePersonalityDto } from "./dto/create-personality.dto";
import { Personality } from "./entities/personality.entity"; import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
import type { PersonalityResponse } from "./entities/personality.entity";
/** /**
* Service for managing personality/assistant configurations * Service for managing personality/assistant configurations.
*
* Field mapping:
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
* Prisma `isEnabled` <-> API/frontend `isActive`
*/ */
@Injectable() @Injectable()
export class PersonalitiesService { export class PersonalitiesService {
@@ -12,11 +19,30 @@ export class PersonalitiesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/**
* Map a Prisma Personality record to the API response shape.
*/
private toResponse(personality: Personality): PersonalityResponse {
return {
id: personality.id,
workspaceId: personality.workspaceId,
name: personality.name,
description: personality.description,
tone: personality.tone,
formalityLevel: personality.formalityLevel,
systemPromptTemplate: personality.systemPrompt,
isDefault: personality.isDefault,
isActive: personality.isEnabled,
createdAt: personality.createdAt,
updatedAt: personality.updatedAt,
};
}
/** /**
* Create a new personality * Create a new personality
*/ */
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> { async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
// Check for duplicate name // Check for duplicate name within workspace
const existing = await this.prisma.personality.findFirst({ const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name }, where: { workspaceId, name: dto.name },
}); });
@@ -25,7 +51,7 @@ export class PersonalitiesService {
throw new ConflictException(`Personality with name "${dto.name}" already exists`); throw new ConflictException(`Personality with name "${dto.name}" already exists`);
} }
// If creating a default personality, unset other defaults // If creating as default, unset other defaults first
if (dto.isDefault) { if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId); await this.unsetOtherDefaults(workspaceId);
} }
@@ -34,36 +60,43 @@ export class PersonalitiesService {
data: { data: {
workspaceId, workspaceId,
name: dto.name, name: dto.name,
displayName: dto.displayName, displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
description: dto.description ?? null, description: dto.description ?? null,
systemPrompt: dto.systemPrompt, tone: dto.tone,
temperature: dto.temperature ?? null, formalityLevel: dto.formalityLevel,
maxTokens: dto.maxTokens ?? null, systemPrompt: dto.systemPromptTemplate,
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
isDefault: dto.isDefault ?? false, isDefault: dto.isDefault ?? false,
isEnabled: dto.isEnabled ?? true, isEnabled: dto.isActive ?? true,
}, },
}); });
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`); this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
return personality; return this.toResponse(personality);
} }
/** /**
* Find all personalities for a workspace * Find all personalities for a workspace with optional active filter
*/ */
async findAll(workspaceId: string): Promise<Personality[]> { async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
return this.prisma.personality.findMany({ const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
where: { workspaceId },
if (query?.isActive !== undefined) {
where.isEnabled = query.isActive;
}
const personalities = await this.prisma.personality.findMany({
where,
orderBy: [{ isDefault: "desc" }, { name: "asc" }], orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}); });
return personalities.map((p) => this.toResponse(p));
} }
/** /**
* Find a specific personality by ID * Find a specific personality by ID
*/ */
async findOne(workspaceId: string, id: string): Promise<Personality> { async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findUnique({ const personality = await this.prisma.personality.findFirst({
where: { id, workspaceId }, where: { id, workspaceId },
}); });
@@ -71,13 +104,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with ID ${id} not found`); throw new NotFoundException(`Personality with ID ${id} not found`);
} }
return personality; return this.toResponse(personality);
} }
/** /**
* Find a personality by name * Find a personality by name slug
*/ */
async findByName(workspaceId: string, name: string): Promise<Personality> { async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({ const personality = await this.prisma.personality.findFirst({
where: { workspaceId, name }, where: { workspaceId, name },
}); });
@@ -86,13 +119,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with name "${name}" not found`); throw new NotFoundException(`Personality with name "${name}" not found`);
} }
return personality; return this.toResponse(personality);
} }
/** /**
* Find the default personality for a workspace * Find the default (and enabled) personality for a workspace
*/ */
async findDefault(workspaceId: string): Promise<Personality> { async findDefault(workspaceId: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({ const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isEnabled: true }, where: { workspaceId, isDefault: true, isEnabled: true },
}); });
@@ -101,14 +134,18 @@ export class PersonalitiesService {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`); throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
} }
return personality; return this.toResponse(personality);
} }
/** /**
* Update an existing personality * Update an existing personality
*/ */
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> { async update(
// Check existence workspaceId: string,
id: string,
dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
// Check for duplicate name if updating name // Check for duplicate name if updating name
@@ -127,20 +164,43 @@ export class PersonalitiesService {
await this.unsetOtherDefaults(workspaceId, id); await this.unsetOtherDefaults(workspaceId, id);
} }
// Build update data with field mapping
const updateData: {
name?: string;
displayName?: string;
description?: string;
tone?: string;
formalityLevel?: FormalityLevel;
systemPrompt?: string;
isDefault?: boolean;
isEnabled?: boolean;
} = {};
if (dto.name !== undefined) {
updateData.name = dto.name;
updateData.displayName = dto.name;
}
if (dto.description !== undefined) updateData.description = dto.description;
if (dto.tone !== undefined) updateData.tone = dto.tone;
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
const personality = await this.prisma.personality.update({ const personality = await this.prisma.personality.update({
where: { id }, where: { id },
data: dto, data: updateData,
}); });
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`); this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return personality; return this.toResponse(personality);
} }
/** /**
* Delete a personality * Delete a personality
*/ */
async delete(workspaceId: string, id: string): Promise<void> { async delete(workspaceId: string, id: string): Promise<void> {
// Check existence // Verify existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
await this.prisma.personality.delete({ await this.prisma.personality.delete({
@@ -151,23 +211,22 @@ export class PersonalitiesService {
} }
/** /**
* Set a personality as the default * Set a personality as the default (convenience endpoint)
*/ */
async setDefault(workspaceId: string, id: string): Promise<Personality> { async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
// Check existence // Verify existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
// Unset other defaults // Unset other defaults
await this.unsetOtherDefaults(workspaceId, id); await this.unsetOtherDefaults(workspaceId, id);
// Set this one as default
const personality = await this.prisma.personality.update({ const personality = await this.prisma.personality.update({
where: { id }, where: { id },
data: { isDefault: true }, data: { isDefault: true },
}); });
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`); this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
return personality; return this.toResponse(personality);
} }
/** /**
@@ -178,7 +237,7 @@ export class PersonalitiesService {
where: { where: {
workspaceId, workspaceId,
isDefault: true, isDefault: true,
...(excludeId && { id: { not: excludeId } }), ...(excludeId !== undefined && { id: { not: excludeId } }),
}, },
}); });