From 78b71a0eccd8cd421e6e59c8522ab2daee7cb5fd Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 27 Feb 2026 10:42:50 +0000 Subject: [PATCH] feat(api): implement personalities CRUD API (#537) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../migration.sql | 3 + apps/api/prisma/schema.prisma | 4 + apps/api/src/app.module.ts | 2 + .../dto/create-personality.dto.ts | 69 ++--- apps/api/src/personalities/dto/index.ts | 1 + .../dto/personality-query.dto.ts | 12 + .../dto/update-personality.dto.ts | 64 ++--- .../entities/personality.entity.ts | 36 +-- .../personalities.controller.spec.ts | 123 +++++---- .../personalities/personalities.controller.ts | 109 ++++---- .../personalities.service.spec.ts | 246 ++++++++++-------- .../personalities/personalities.service.ts | 133 +++++++--- 12 files changed, 444 insertions(+), 358 deletions(-) create mode 100644 apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql create mode 100644 apps/api/src/personalities/dto/personality-query.dto.ts diff --git a/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql new file mode 100644 index 0000000..fd88954 --- /dev/null +++ b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql @@ -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'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 9641c47..35d474b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 945f6e4..79810b8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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: [ diff --git a/apps/api/src/personalities/dto/create-personality.dto.ts b/apps/api/src/personalities/dto/create-personality.dto.ts index 81cb86e..cc006d7 100644 --- a/apps/api/src/personalities/dto/create-personality.dto.ts +++ b/apps/api/src/personalities/dto/create-personality.dto.ts @@ -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; } diff --git a/apps/api/src/personalities/dto/index.ts b/apps/api/src/personalities/dto/index.ts index b33be96..aca0d0b 100644 --- a/apps/api/src/personalities/dto/index.ts +++ b/apps/api/src/personalities/dto/index.ts @@ -1,2 +1,3 @@ export * from "./create-personality.dto"; export * from "./update-personality.dto"; +export * from "./personality-query.dto"; diff --git a/apps/api/src/personalities/dto/personality-query.dto.ts b/apps/api/src/personalities/dto/personality-query.dto.ts new file mode 100644 index 0000000..786ac64 --- /dev/null +++ b/apps/api/src/personalities/dto/personality-query.dto.ts @@ -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; +} diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts index 4098592..937ab4a 100644 --- a/apps/api/src/personalities/dto/update-personality.dto.ts +++ b/apps/api/src/personalities/dto/update-personality.dto.ts @@ -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; } diff --git a/apps/api/src/personalities/entities/personality.entity.ts b/apps/api/src/personalities/entities/personality.entity.ts index e685121..5ef146d 100644 --- a/apps/api/src/personalities/entities/personality.entity.ts +++ b/apps/api/src/personalities/entities/personality.entity.ts @@ -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; } diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts index 8e1dc23..ef767c1 100644 --- a/apps/api/src/personalities/personalities.controller.spec.ts +++ b/apps/api/src/personalities/personalities.controller.spec.ts @@ -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); service = module.get(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); diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts index 79714de..ba1f2ef 100644 --- a/apps/api/src/personalities/personalities.controller.ts +++ b/apps/api/src/personalities/personalities.controller.ts @@ -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 { - 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 { - return this.personalitiesService.findDefault(req.workspaceId); + @RequirePermission(Permission.WORKSPACE_ANY) + async findDefault(@Workspace() workspaceId: string): Promise { + 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 { - 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 { - return this.personalitiesService.findOne(req.workspaceId, id); + @RequirePermission(Permission.WORKSPACE_ANY) + async findOne( + @Workspace() workspaceId: string, + @Param("id") id: string + ): Promise { + 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 { - return this.personalitiesService.create(req.workspaceId, dto); + ): Promise { + 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 { - return this.personalitiesService.update(req.workspaceId, id, dto); + ): Promise { + 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 { - return this.personalitiesService.delete(req.workspaceId, id); + @RequirePermission(Permission.WORKSPACE_MEMBER) + async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise { + 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 { - return this.personalitiesService.setDefault(req.workspaceId, id); + ): Promise { + return this.personalitiesService.setDefault(workspaceId, id); } } diff --git a/apps/api/src/personalities/personalities.service.spec.ts b/apps/api/src/personalities/personalities.service.spec.ts index b0e1b20..2cc1c02 100644 --- a/apps/api/src/personalities/personalities.service.spec.ts +++ b/apps/api/src/personalities/personalities.service.spec.ts @@ -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); prisma = module.get(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 () => { + 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({ - ...mockPersonality, - ...createDto, - id: "new-personality-id", - isDefault: false, - isEnabled: true, - }); + 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 diff --git a/apps/api/src/personalities/personalities.service.ts b/apps/api/src/personalities/personalities.service.ts index e766c8a..cf5a7f0 100644 --- a/apps/api/src/personalities/personalities.service.ts +++ b/apps/api/src/personalities/personalities.service.ts @@ -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 { - // Check for duplicate name + async create(workspaceId: string, dto: CreatePersonalityDto): Promise { + // 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 { - return this.prisma.personality.findMany({ - where: { workspaceId }, + async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise { + 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 { - const personality = await this.prisma.personality.findUnique({ + async findOne(workspaceId: string, id: string): Promise { + 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 { + async findByName(workspaceId: string, name: string): Promise { 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 { + async findDefault(workspaceId: string): Promise { 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 { - // Check existence + async update( + workspaceId: string, + id: string, + dto: UpdatePersonalityDto + ): Promise { + // 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 { - // 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 { - // Check existence + async setDefault(workspaceId: string, id: string): Promise { + // 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 } }), }, });