feat(#82): implement Personality Module

- Add Personality model to Prisma schema with FormalityLevel enum
- Create migration and seed with 6 default personalities
- Implement CRUD API with TDD approach (97.67% coverage)
  * PersonalitiesService: findAll, findOne, findDefault, create, update, remove
  * PersonalitiesController: REST endpoints with auth guards
  * Comprehensive test coverage (21 passing tests)
- Add Personality types to shared package
- Create frontend components:
  * PersonalitySelector: dropdown for choosing personality
  * PersonalityPreview: preview personality style and system prompt
  * PersonalityForm: create/edit personalities with validation
  * Settings page: manage personalities with CRUD operations
- Integrate with Ollama API:
  * Support personalityId in chat endpoint
  * Auto-inject system prompt from personality
  * Fall back to default personality if not specified
- API client for frontend personality management

All tests passing with 97.67% backend coverage (exceeds 85% requirement)
This commit is contained in:
Jason Woltje
2026-01-29 17:57:54 -06:00
parent 95833fb4ea
commit 5dd46c85af
43 changed files with 4782 additions and 2 deletions

View File

@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import { validate } from "class-validator";
import { plainToClass } from "class-transformer";
import { BaseFilterDto, BasePaginationDto, SortOrder } from "./base-filter.dto";
describe("BasePaginationDto", () => {
it("should accept valid pagination parameters", async () => {
const dto = plainToClass(BasePaginationDto, {
page: 1,
limit: 20,
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.page).toBe(1);
expect(dto.limit).toBe(20);
});
it("should use default values when not provided", async () => {
const dto = plainToClass(BasePaginationDto, {});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it("should reject page less than 1", async () => {
const dto = plainToClass(BasePaginationDto, {
page: 0,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("page");
});
it("should reject limit less than 1", async () => {
const dto = plainToClass(BasePaginationDto, {
limit: 0,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("limit");
});
it("should reject limit greater than 100", async () => {
const dto = plainToClass(BasePaginationDto, {
limit: 101,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("limit");
});
it("should transform string numbers to integers", async () => {
const dto = plainToClass(BasePaginationDto, {
page: "2" as any,
limit: "30" as any,
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.page).toBe(2);
expect(dto.limit).toBe(30);
});
});
describe("BaseFilterDto", () => {
it("should accept valid search parameter", async () => {
const dto = plainToClass(BaseFilterDto, {
search: "test query",
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.search).toBe("test query");
});
it("should accept valid sortBy parameter", async () => {
const dto = plainToClass(BaseFilterDto, {
sortBy: "createdAt",
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.sortBy).toBe("createdAt");
});
it("should accept valid sortOrder parameter", async () => {
const dto = plainToClass(BaseFilterDto, {
sortOrder: SortOrder.DESC,
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.sortOrder).toBe(SortOrder.DESC);
});
it("should reject invalid sortOrder", async () => {
const dto = plainToClass(BaseFilterDto, {
sortOrder: "invalid" as any,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "sortOrder")).toBe(true);
});
it("should accept comma-separated sortBy fields", async () => {
const dto = plainToClass(BaseFilterDto, {
sortBy: "priority,createdAt",
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.sortBy).toBe("priority,createdAt");
});
it("should accept date range filters", async () => {
const dto = plainToClass(BaseFilterDto, {
dateFrom: "2024-01-01T00:00:00Z",
dateTo: "2024-12-31T23:59:59Z",
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it("should reject invalid date format for dateFrom", async () => {
const dto = plainToClass(BaseFilterDto, {
dateFrom: "not-a-date",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "dateFrom")).toBe(true);
});
it("should reject invalid date format for dateTo", async () => {
const dto = plainToClass(BaseFilterDto, {
dateTo: "not-a-date",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "dateTo")).toBe(true);
});
it("should trim whitespace from search query", async () => {
const dto = plainToClass(BaseFilterDto, {
search: " test query ",
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.search).toBe("test query");
});
it("should reject search queries longer than 500 characters", async () => {
const longString = "a".repeat(501);
const dto = plainToClass(BaseFilterDto, {
search: longString,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.property === "search")).toBe(true);
});
});

View File

@@ -0,0 +1,82 @@
import {
IsOptional,
IsInt,
Min,
Max,
IsString,
IsEnum,
IsDateString,
MaxLength,
} from "class-validator";
import { Type, Transform } from "class-transformer";
/**
* Enum for sort order
*/
export enum SortOrder {
ASC = "asc",
DESC = "desc",
}
/**
* Base DTO for pagination
*/
export class BasePaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number = 50;
}
/**
* Base DTO for filtering and sorting
* Provides common filtering capabilities across all entities
*/
export class BaseFilterDto extends BasePaginationDto {
/**
* Full-text search query
* Searches across title, description, and other text fields
*/
@IsOptional()
@IsString({ message: "search must be a string" })
@MaxLength(500, { message: "search must not exceed 500 characters" })
@Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
search?: string;
/**
* Field(s) to sort by
* Can be comma-separated for multi-field sorting (e.g., "priority,createdAt")
*/
@IsOptional()
@IsString({ message: "sortBy must be a string" })
sortBy?: string;
/**
* Sort order (ascending or descending)
*/
@IsOptional()
@IsEnum(SortOrder, { message: "sortOrder must be either 'asc' or 'desc'" })
sortOrder?: SortOrder = SortOrder.DESC;
/**
* Filter by date range - start date
*/
@IsOptional()
@IsDateString({}, { message: "dateFrom must be a valid ISO 8601 date string" })
dateFrom?: Date;
/**
* Filter by date range - end date
*/
@IsOptional()
@IsDateString({}, { message: "dateTo must be a valid ISO 8601 date string" })
dateTo?: Date;
}

View File

@@ -0,0 +1 @@
export * from "./base-filter.dto";