Files
stack/apps/api/src/personalities/personalities.service.ts
Jason Woltje 78b71a0ecc
All checks were successful
ci/woodpecker/push/api Pipeline was successful
feat(api): implement personalities CRUD API (#537)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-27 10:42:50 +00:00

252 lines
7.6 KiB
TypeScript

import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
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.
*
* Field mapping:
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
* Prisma `isEnabled` <-> API/frontend `isActive`
*/
@Injectable()
export class PersonalitiesService {
private readonly logger = new Logger(PersonalitiesService.name);
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<PersonalityResponse> {
// Check for duplicate name within workspace
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name },
});
if (existing) {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
// If creating as default, unset other defaults first
if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId);
}
const personality = await this.prisma.personality.create({
data: {
workspaceId,
name: dto.name,
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
description: dto.description ?? null,
tone: dto.tone,
formalityLevel: dto.formalityLevel,
systemPrompt: dto.systemPromptTemplate,
isDefault: dto.isDefault ?? false,
isEnabled: dto.isActive ?? true,
},
});
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
return this.toResponse(personality);
}
/**
* Find all personalities for a workspace with optional active filter
*/
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<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({
where: { id, workspaceId },
});
if (!personality) {
throw new NotFoundException(`Personality with ID ${id} not found`);
}
return this.toResponse(personality);
}
/**
* Find a personality by name slug
*/
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, name },
});
if (!personality) {
throw new NotFoundException(`Personality with name "${name}" not found`);
}
return this.toResponse(personality);
}
/**
* Find the default (and enabled) personality for a workspace
*/
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isEnabled: true },
});
if (!personality) {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
}
return this.toResponse(personality);
}
/**
* Update an existing personality
*/
async update(
workspaceId: string,
id: string,
dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id);
// Check for duplicate name if updating name
if (dto.name) {
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name, id: { not: id } },
});
if (existing) {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
}
// If setting as default, unset other defaults
if (dto.isDefault === true) {
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: updateData,
});
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return this.toResponse(personality);
}
/**
* Delete a personality
*/
async delete(workspaceId: string, id: string): Promise<void> {
// Verify existence
await this.findOne(workspaceId, id);
await this.prisma.personality.delete({
where: { id },
});
this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`);
}
/**
* Set a personality as the default (convenience endpoint)
*/
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id);
// Unset other defaults
await this.unsetOtherDefaults(workspaceId, id);
const personality = await this.prisma.personality.update({
where: { id },
data: { isDefault: true },
});
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
return this.toResponse(personality);
}
/**
* Unset the default flag on all other personalities in the workspace
*/
private async unsetOtherDefaults(workspaceId: string, excludeId?: string): Promise<void> {
const currentDefault = await this.prisma.personality.findFirst({
where: {
workspaceId,
isDefault: true,
...(excludeId !== undefined && { id: { not: excludeId } }),
},
});
if (currentDefault) {
await this.prisma.personality.update({
where: { id: currentDefault.id },
data: { isDefault: false },
});
}
}
}