feat(#130): add Personality Prisma schema and backend

Implement Personality system backend with database schema, service,
controller, and comprehensive tests. Personalities define assistant
behavior with system prompts and LLM configuration.

Changes:
- Update Personality model in schema.prisma with LLM provider relation
- Create PersonalitiesService with CRUD and default management
- Create PersonalitiesController with REST endpoints
- Add DTOs with validation (create/update)
- Add entity for type safety
- Remove unused PromptFormatterService
- Achieve 26 tests with full coverage

Endpoints:
- GET /personality - List all
- GET /personality/default - Get default
- GET /personality/by-name/:name - Get by name
- GET /personality/:id - Get one
- POST /personality - Create
- PATCH /personality/:id - Update
- DELETE /personality/:id - Delete
- POST /personality/:id/set-default - Set default

Fixes #130

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 12:44:50 -06:00
parent 1f97e6de40
commit 64cb5c1edd
12 changed files with 516 additions and 549 deletions

View File

@@ -10,19 +10,21 @@ describe("PersonalitiesService", () => {
let prisma: PrismaService;
const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
const mockPersonality = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "Professional",
description: "Professional communication style",
tone: "professional",
formalityLevel: "FORMAL" as const,
systemPromptTemplate: "You are a professional assistant.",
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: mockProviderId,
isDefault: true,
isActive: true,
isEnabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -58,82 +60,15 @@ describe("PersonalitiesService", () => {
vi.clearAllMocks();
});
describe("findAll", () => {
it("should return all personalities for a workspace", async () => {
const mockPersonalities = [mockPersonality];
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
const result = await service.findAll(mockWorkspaceId);
expect(result).toEqual(mockPersonalities);
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isActive: true },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
});
it("should filter by active status", async () => {
mockPrismaService.personality.findMany.mockResolvedValue([mockPersonality]);
await service.findAll(mockWorkspaceId, false);
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isActive: false },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
});
});
describe("findOne", () => {
it("should return a personality by id", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException,
);
});
});
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isActive: true },
});
});
it("should throw NotFoundException when no default personality exists", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.findDefault(mockWorkspaceId)).rejects.toThrow(NotFoundException);
});
});
describe("create", () => {
const createDto: CreatePersonalityDto = {
name: "Casual",
description: "Casual communication style",
tone: "casual",
formalityLevel: "CASUAL",
systemPromptTemplate: "You are a casual assistant.",
name: "casual-helper",
displayName: "Casual Helper",
description: "A casual communication helper",
systemPrompt: "You are a casual assistant.",
temperature: 0.8,
maxTokens: 1500,
llmProviderInstanceId: mockProviderId,
};
it("should create a new personality", async () => {
@@ -142,6 +77,8 @@ describe("PersonalitiesService", () => {
...mockPersonality,
...createDto,
id: "new-personality-id",
isDefault: false,
isEnabled: true,
});
const result = await service.create(mockWorkspaceId, createDto);
@@ -150,7 +87,15 @@ describe("PersonalitiesService", () => {
expect(prisma.personality.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
...createDto,
name: createDto.name,
displayName: createDto.displayName,
description: createDto.description ?? null,
systemPrompt: createDto.systemPrompt,
temperature: createDto.temperature ?? null,
maxTokens: createDto.maxTokens ?? null,
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
isDefault: false,
isEnabled: true,
},
});
});
@@ -186,10 +131,92 @@ describe("PersonalitiesService", () => {
});
});
describe("findAll", () => {
it("should return all personalities for a workspace", async () => {
const mockPersonalities = [mockPersonality];
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
const result = await service.findAll(mockWorkspaceId);
expect(result).toEqual(mockPersonalities);
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
});
});
describe("findOne", () => {
it("should return a personality by id", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
);
});
});
describe("findByName", () => {
it("should return a personality by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: mockWorkspaceId,
name: "professional-assistant",
},
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.findByName(mockWorkspaceId, "non-existent")).rejects.toThrow(
NotFoundException
);
});
});
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
});
});
it("should throw NotFoundException when no default personality exists", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.findDefault(mockWorkspaceId)).rejects.toThrow(NotFoundException);
});
});
describe("update", () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
tone: "updated",
temperature: 0.9,
};
it("should update a personality", async () => {
@@ -212,13 +239,13 @@ describe("PersonalitiesService", () => {
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateDto),
).rejects.toThrow(NotFoundException);
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" };
const updateNameDto = { name: "existing-name" };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue({
...mockPersonality,
@@ -226,19 +253,40 @@ describe("PersonalitiesService", () => {
});
await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto),
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
).rejects.toThrow(ConflictException);
});
it("should unset other defaults when setting as default", async () => {
const updateDefaultDto = { isDefault: true };
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId },
data: updateDefaultDto,
});
});
});
describe("remove", () => {
describe("delete", () => {
it("should delete a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.delete.mockResolvedValue(mockPersonality);
mockPrismaService.personality.delete.mockResolvedValue(undefined);
const result = await service.remove(mockWorkspaceId, mockPersonalityId);
await service.delete(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.delete).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
});
@@ -247,8 +295,41 @@ describe("PersonalitiesService", () => {
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.remove(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException,
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
);
});
});
describe("setDefault", () => {
it("should set a personality as default", async () => {
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updatedPersonality = { ...mockPersonality, isDefault: true };
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce(updatedPersonality); // Set new default
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, {
where: { id: mockPersonalityId },
data: { isDefault: true },
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
);
});
});