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")
description String? @db.Text
// Tone and formality
tone String @default("neutral")
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
// System prompt
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 { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -105,6 +106,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
SpeechModule,
DashboardModule,
TerminalModule,
PersonalitiesModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -1,59 +1,38 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
import { FormalityLevel } from "@prisma/client";
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } 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 {
@IsString()
@MinLength(1)
@MaxLength(100)
name!: string; // unique identifier slug
@IsString()
@MinLength(1)
@MaxLength(200)
displayName!: string; // human-readable name
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name!: string;
@IsOptional()
@IsString()
@MaxLength(1000)
@IsString({ message: "description must be a string" })
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
description?: string;
@IsString()
@MinLength(10)
systemPrompt!: string;
@IsString({ message: "tone must be a string" })
@MinLength(1, { message: "tone must not be empty" })
@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()
@IsNumber()
@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()
@IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
}

View File

@@ -1,2 +1,3 @@
export * from "./create-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 {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
import { FormalityLevel } from "@prisma/client";
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } 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 {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
name?: string; // unique identifier slug
@IsString({ message: "name must be a string" })
@MinLength(1, { message: "name must not be empty" })
@MaxLength(255, { message: "name must not exceed 255 characters" })
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(200)
displayName?: string; // human-readable name
@IsOptional()
@IsString()
@MaxLength(1000)
@IsString({ message: "description must be a string" })
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
description?: string;
@IsOptional()
@IsString()
@MinLength(10)
systemPrompt?: string;
@IsString({ message: "tone must be a string" })
@MinLength(1, { message: "tone must not be empty" })
@MaxLength(100, { message: "tone must not exceed 100 characters" })
tone?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number; // null = use provider default
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
formalityLevel?: FormalityLevel;
@IsOptional()
@IsInt()
@Min(1)
maxTokens?: number; // null = use provider default
@IsString({ message: "systemPromptTemplate must be a string" })
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
systemPromptTemplate?: string;
@IsOptional()
@IsUUID("4")
llmProviderInstanceId?: string; // FK to LlmProviderInstance
@IsOptional()
@IsBoolean()
@IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@IsBoolean({ message: "isActive must be a 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 {
id!: string;
workspaceId!: string;
name!: string; // unique identifier slug
displayName!: string; // human-readable name
description!: string | null;
systemPrompt!: string;
temperature!: number | null; // null = use provider default
maxTokens!: number | null; // null = use provider default
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
isDefault!: boolean;
isEnabled!: boolean;
createdAt!: Date;
updatedAt!: Date;
export interface PersonalityResponse {
id: string;
workspaceId: string;
name: string;
description: string | null;
tone: string;
formalityLevel: FormalityLevel;
systemPromptTemplate: string;
isDefault: boolean;
isActive: boolean;
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 { PersonalitiesController } from "./personalities.controller";
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 { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesController", () => {
let controller: PersonalitiesController;
let service: PersonalitiesService;
const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123";
/** API response shape (frontend field names) */
const mockPersonality = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
displayName: "Professional Assistant",
description: "A professional communication assistant",
systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7,
maxTokens: 2000,
llmProviderInstanceId: "provider-123",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
isDefault: true,
isEnabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockRequest = {
user: { id: mockUserId },
workspaceId: mockWorkspaceId,
isActive: true,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
};
const mockPersonalitiesService = {
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
})
.overrideGuard(AuthGuard)
.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();
controller = module.get<PersonalitiesController>(PersonalitiesController);
service = module.get<PersonalitiesService>(PersonalitiesService);
// Reset mocks
vi.clearAllMocks();
});
describe("findAll", () => {
it("should return all personalities", async () => {
const mockPersonalities = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
it("should return success response with personalities list", async () => {
const mockList = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
const result = await controller.findAll(mockRequest);
const result = await controller.findAll(mockWorkspaceId, {});
expect(result).toEqual(mockPersonalities);
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
});
expect(result).toEqual({ success: true, data: mockList });
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
});
describe("findOne", () => {
it("should return a personality by id", async () => {
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
it("should pass isActive query filter to service", async () => {
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
const result = await controller.findOne(mockRequest, mockPersonalityId);
await controller.findAll(mockWorkspaceId, { isActive: true });
expect(result).toEqual(mockPersonality);
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");
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
});
});
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
const result = await controller.findDefault(mockRequest);
const result = await controller.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
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", () => {
it("should create a new personality", async () => {
const createDto: CreatePersonalityDto = {
name: "casual-helper",
displayName: "Casual Helper",
description: "A casual helper",
systemPrompt: "You are a casual assistant.",
temperature: 0.8,
maxTokens: 1500,
tone: "casual",
formalityLevel: FormalityLevel.CASUAL,
systemPromptTemplate: "You are a casual assistant.",
};
mockPersonalitiesService.create.mockResolvedValue({
...mockPersonality,
...createDto,
});
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
mockPersonalitiesService.create.mockResolvedValue(created);
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);
});
});
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
it("should update a personality", async () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
temperature: 0.9,
tone: "enthusiastic",
};
mockPersonalitiesService.update.mockResolvedValue({
...mockPersonality,
...updateDto,
});
const updated = { ...mockPersonality, ...updateDto };
mockPersonalitiesService.update.mockResolvedValue(updated);
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto);
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
it("should delete a personality", async () => {
mockPersonalitiesService.delete.mockResolvedValue(undefined);
await controller.delete(mockRequest, mockPersonalityId);
await controller.delete(mockWorkspaceId, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
});
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
mockPersonalitiesService.setDefault.mockResolvedValue({
...mockPersonality,
isDefault: true,
});
const updated = { ...mockPersonality, isDefault: true };
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
const result = await controller.setDefault(mockRequest, mockPersonalityId);
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true });
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);

View File

@@ -6,105 +6,122 @@ import {
Delete,
Body,
Param,
Query,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from "@nestjs/common";
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 { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity";
interface AuthenticatedRequest {
user: { id: string };
workspaceId: string;
}
import { CreatePersonalityDto } from "./dto/create-personality.dto";
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { PersonalityQueryDto } from "./dto/personality-query.dto";
import type { PersonalityResponse } from "./entities/personality.entity";
/**
* 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")
@UseGuards(AuthGuard)
@Controller("personalities")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class PersonalitiesController {
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()
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
return this.personalitiesService.findAll(req.workspaceId);
@RequirePermission(Permission.WORKSPACE_ANY)
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")
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
return this.personalitiesService.findDefault(req.workspaceId);
@RequirePermission(Permission.WORKSPACE_ANY)
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
return this.personalitiesService.findDefault(workspaceId);
}
/**
* Get a personality by its unique name
*/
@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 /api/personalities/:id
* Get a single personality by ID.
*/
@Get(":id")
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
return this.personalitiesService.findOne(req.workspaceId, id);
@RequirePermission(Permission.WORKSPACE_ANY)
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()
@HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(
@Req() req: AuthenticatedRequest,
@Workspace() workspaceId: string,
@Body() dto: CreatePersonalityDto
): Promise<Personality> {
return this.personalitiesService.create(req.workspaceId, dto);
): Promise<PersonalityResponse> {
return this.personalitiesService.create(workspaceId, dto);
}
/**
* Update a personality
* PATCH /api/personalities/:id
* Update an existing personality.
*/
@Patch(":id")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async update(
@Req() req: AuthenticatedRequest,
@Workspace() workspaceId: string,
@Param("id") id: string,
@Body() dto: UpdatePersonalityDto
): Promise<Personality> {
return this.personalitiesService.update(req.workspaceId, id, dto);
): Promise<PersonalityResponse> {
return this.personalitiesService.update(workspaceId, id, dto);
}
/**
* Delete a personality
* DELETE /api/personalities/:id
* Delete a personality.
*/
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
return this.personalitiesService.delete(req.workspaceId, id);
@RequirePermission(Permission.WORKSPACE_MEMBER)
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")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async setDefault(
@Req() req: AuthenticatedRequest,
@Workspace() workspaceId: string,
@Param("id") id: string
): Promise<Personality> {
return this.personalitiesService.setDefault(req.workspaceId, id);
): Promise<PersonalityResponse> {
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 { PersonalitiesService } from "./personalities.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 { FormalityLevel } from "@prisma/client";
describe("PersonalitiesService", () => {
let service: PersonalitiesService;
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
const mockWorkspaceId = "workspace-123";
const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
const mockPersonality = {
/** Raw Prisma record shape (uses Prisma field names) */
const mockPrismaRecord = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
displayName: "Professional Assistant",
description: "A professional communication assistant",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7,
maxTokens: 2000,
llmProviderInstanceId: mockProviderId,
llmProviderInstanceId: "provider-123",
isDefault: true,
isEnabled: true,
createdAt: new Date(),
updatedAt: new Date(),
createdAt: new Date("2026-01-01"),
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 = {
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn((callback) => callback(mockPrismaService)),
};
beforeEach(async () => {
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
service = module.get<PersonalitiesService>(PersonalitiesService);
prisma = module.get<PrismaService>(PrismaService);
// Reset mocks
vi.clearAllMocks();
});
describe("create", () => {
const createDto: CreatePersonalityDto = {
name: "casual-helper",
displayName: "Casual Helper",
description: "A casual communication helper",
systemPrompt: "You are a casual assistant.",
temperature: 0.8,
maxTokens: 1500,
llmProviderInstanceId: mockProviderId,
tone: "casual",
formalityLevel: FormalityLevel.CASUAL,
systemPromptTemplate: "You are a casual assistant.",
isDefault: false,
isActive: true,
};
it("should create a new personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.create.mockResolvedValue({
...mockPersonality,
...createDto,
id: "new-personality-id",
const createdRecord = {
...mockPrismaRecord,
name: createDto.name,
description: createDto.description,
tone: createDto.tone,
formalityLevel: createDto.formalityLevel,
systemPrompt: createDto.systemPromptTemplate,
isDefault: false,
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);
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({
data: {
workspaceId: mockWorkspaceId,
name: createDto.name,
displayName: createDto.displayName,
displayName: createDto.name,
description: createDto.description ?? null,
systemPrompt: createDto.systemPrompt,
temperature: createDto.temperature ?? null,
maxTokens: createDto.maxTokens ?? null,
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
tone: createDto.tone,
formalityLevel: createDto.formalityLevel,
systemPrompt: createDto.systemPromptTemplate,
isDefault: false,
isEnabled: true,
},
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
});
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);
});
it("should unset other defaults when creating a new default personality", async () => {
const createDefaultDto = { ...createDto, isDefault: true };
// First call to findFirst checks for name conflict (should be null)
// Second call to findFirst finds the existing default personality
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(null) // No name conflict
.mockResolvedValueOnce(mockPersonality); // Existing default
mockPrismaService.personality.update.mockResolvedValue({
...mockPersonality,
isDefault: false,
});
.mockResolvedValueOnce(null) // name conflict check
.mockResolvedValueOnce(otherDefault); // existing default lookup
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
mockPrismaService.personality.create.mockResolvedValue({
...mockPersonality,
...createDefaultDto,
...createdRecord,
isDefault: true,
});
await service.create(mockWorkspaceId, createDefaultDto);
expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
where: { id: "other-id" },
data: { isDefault: false },
});
});
});
describe("findAll", () => {
it("should return all personalities for a workspace", async () => {
const mockPersonalities = [mockPersonality];
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
it("should return mapped response list for a workspace", async () => {
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
const result = await service.findAll(mockWorkspaceId);
expect(result).toEqual(mockPersonalities);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockResponse);
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
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", () => {
it("should return a personality by id", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
it("should return a mapped personality response by id", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
});
});
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(
NotFoundException
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
});
describe("findByName", () => {
it("should return a personality by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
it("should return a mapped personality response by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
expect(result).toEqual(mockPersonality);
expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
name: "professional-assistant",
},
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
});
});
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
});
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
describe("update", () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
temperature: 0.9,
tone: "formal",
isActive: false,
};
it("should update a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.update.mockResolvedValue({
...mockPersonality,
...updateDto,
});
it("should update a personality and return mapped response", async () => {
const updatedRecord = {
...mockPrismaRecord,
description: updateDto.description,
tone: updateDto.tone,
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);
expect(result).toMatchObject(updateDto);
expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
data: updateDto,
});
expect(result.description).toBe(updateDto.description);
expect(result.tone).toBe(updateDto.tone);
expect(result.isActive).toBe(false);
});
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(
NotFoundException
);
});
it("should throw ConflictException when updating to existing name", async () => {
const updateNameDto = { name: "existing-name" };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue({
...mockPersonality,
id: "different-id",
});
it("should throw ConflictException when updating to an existing name", async () => {
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(conflictRecord); // name conflict
await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
});
it("should unset other defaults when setting as default", async () => {
const updateDefaultDto = { isDefault: true };
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce(updatedRecord);
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId },
data: updateDefaultDto,
});
});
});
describe("delete", () => {
it("should delete a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.delete.mockResolvedValue(undefined);
await service.delete(mockWorkspaceId, mockPersonalityId);
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
});
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(
NotFoundException
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updatedPersonality = { ...mockPersonality, isDefault: true };
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce(updatedPersonality); // Set new default
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce(updatedRecord);
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true });
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
expect(result.isDefault).toBe(true);
expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
data: { isDefault: true },
});
});
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(
NotFoundException

View File

@@ -1,10 +1,17 @@
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity";
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
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()
export class PersonalitiesService {
@@ -12,11 +19,30 @@ export class PersonalitiesService {
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
*/
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
// Check for duplicate name
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
// Check for duplicate name within workspace
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name },
});
@@ -25,7 +51,7 @@ export class PersonalitiesService {
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) {
await this.unsetOtherDefaults(workspaceId);
}
@@ -34,36 +60,43 @@ export class PersonalitiesService {
data: {
workspaceId,
name: dto.name,
displayName: dto.displayName,
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
description: dto.description ?? null,
systemPrompt: dto.systemPrompt,
temperature: dto.temperature ?? null,
maxTokens: dto.maxTokens ?? null,
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
tone: dto.tone,
formalityLevel: dto.formalityLevel,
systemPrompt: dto.systemPromptTemplate,
isDefault: dto.isDefault ?? false,
isEnabled: dto.isEnabled ?? true,
isEnabled: dto.isActive ?? true,
},
});
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[]> {
return this.prisma.personality.findMany({
where: { workspaceId },
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
if (query?.isActive !== undefined) {
where.isEnabled = query.isActive;
}
const personalities = await this.prisma.personality.findMany({
where,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
return personalities.map((p) => this.toResponse(p));
}
/**
* Find a specific personality by ID
*/
async findOne(workspaceId: string, id: string): Promise<Personality> {
const personality = await this.prisma.personality.findUnique({
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({
where: { id, workspaceId },
});
@@ -71,13 +104,13 @@ export class PersonalitiesService {
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({
where: { workspaceId, name },
});
@@ -86,13 +119,13 @@ export class PersonalitiesService {
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({
where: { workspaceId, isDefault: true, isEnabled: true },
});
@@ -101,14 +134,18 @@ export class PersonalitiesService {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
}
return personality;
return this.toResponse(personality);
}
/**
* Update an existing personality
*/
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
// Check existence
async update(
workspaceId: string,
id: string,
dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id);
// Check for duplicate name if updating name
@@ -127,20 +164,43 @@ export class PersonalitiesService {
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({
where: { id },
data: dto,
data: updateData,
});
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return personality;
return this.toResponse(personality);
}
/**
* Delete a personality
*/
async delete(workspaceId: string, id: string): Promise<void> {
// Check existence
// Verify existence
await this.findOne(workspaceId, id);
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> {
// Check existence
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id);
// Unset other defaults
await this.unsetOtherDefaults(workspaceId, id);
// Set this one as default
const personality = await this.prisma.personality.update({
where: { id },
data: { isDefault: true },
});
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: {
workspaceId,
isDefault: true,
...(excludeId && { id: { not: excludeId } }),
...(excludeId !== undefined && { id: { not: excludeId } }),
},
});