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";

View File

@@ -0,0 +1 @@
export * from "./query-builder";

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import { QueryBuilder } from "./query-builder";
import { SortOrder } from "../dto";
describe("QueryBuilder", () => {
describe("buildSearchFilter", () => {
it("should return empty object when search is undefined", () => {
const result = QueryBuilder.buildSearchFilter(undefined, ["title", "description"]);
expect(result).toEqual({});
});
it("should return empty object when search is empty string", () => {
const result = QueryBuilder.buildSearchFilter("", ["title", "description"]);
expect(result).toEqual({});
});
it("should build OR filter for multiple fields", () => {
const result = QueryBuilder.buildSearchFilter("test", ["title", "description"]);
expect(result).toEqual({
OR: [
{ title: { contains: "test", mode: "insensitive" } },
{ description: { contains: "test", mode: "insensitive" } },
],
});
});
it("should handle single field", () => {
const result = QueryBuilder.buildSearchFilter("test", ["title"]);
expect(result).toEqual({
OR: [
{ title: { contains: "test", mode: "insensitive" } },
],
});
});
it("should trim search query", () => {
const result = QueryBuilder.buildSearchFilter(" test ", ["title"]);
expect(result).toEqual({
OR: [
{ title: { contains: "test", mode: "insensitive" } },
],
});
});
});
describe("buildSortOrder", () => {
it("should return default sort when sortBy is undefined", () => {
const result = QueryBuilder.buildSortOrder(undefined, undefined, { createdAt: "desc" });
expect(result).toEqual({ createdAt: "desc" });
});
it("should build single field sort", () => {
const result = QueryBuilder.buildSortOrder("title", SortOrder.ASC);
expect(result).toEqual({ title: "asc" });
});
it("should build multi-field sort", () => {
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.DESC);
expect(result).toEqual([
{ priority: "desc" },
{ dueDate: "desc" },
]);
});
it("should handle mixed sorting with custom order per field", () => {
const result = QueryBuilder.buildSortOrder("priority:asc,dueDate:desc");
expect(result).toEqual([
{ priority: "asc" },
{ dueDate: "desc" },
]);
});
it("should use default order when not specified per field", () => {
const result = QueryBuilder.buildSortOrder("priority,dueDate", SortOrder.ASC);
expect(result).toEqual([
{ priority: "asc" },
{ dueDate: "asc" },
]);
});
});
describe("buildDateRangeFilter", () => {
it("should return empty object when both dates are undefined", () => {
const result = QueryBuilder.buildDateRangeFilter("createdAt", undefined, undefined);
expect(result).toEqual({});
});
it("should build gte filter when only from date is provided", () => {
const date = new Date("2024-01-01");
const result = QueryBuilder.buildDateRangeFilter("createdAt", date, undefined);
expect(result).toEqual({
createdAt: { gte: date },
});
});
it("should build lte filter when only to date is provided", () => {
const date = new Date("2024-12-31");
const result = QueryBuilder.buildDateRangeFilter("createdAt", undefined, date);
expect(result).toEqual({
createdAt: { lte: date },
});
});
it("should build both gte and lte filters when both dates provided", () => {
const fromDate = new Date("2024-01-01");
const toDate = new Date("2024-12-31");
const result = QueryBuilder.buildDateRangeFilter("createdAt", fromDate, toDate);
expect(result).toEqual({
createdAt: {
gte: fromDate,
lte: toDate,
},
});
});
});
describe("buildInFilter", () => {
it("should return empty object when values is undefined", () => {
const result = QueryBuilder.buildInFilter("status", undefined);
expect(result).toEqual({});
});
it("should return empty object when values is empty array", () => {
const result = QueryBuilder.buildInFilter("status", []);
expect(result).toEqual({});
});
it("should build in filter for single value", () => {
const result = QueryBuilder.buildInFilter("status", ["ACTIVE"]);
expect(result).toEqual({
status: { in: ["ACTIVE"] },
});
});
it("should build in filter for multiple values", () => {
const result = QueryBuilder.buildInFilter("status", ["ACTIVE", "PENDING"]);
expect(result).toEqual({
status: { in: ["ACTIVE", "PENDING"] },
});
});
it("should handle single value as string", () => {
const result = QueryBuilder.buildInFilter("status", "ACTIVE" as any);
expect(result).toEqual({
status: { in: ["ACTIVE"] },
});
});
});
describe("buildPaginationParams", () => {
it("should use default values when not provided", () => {
const result = QueryBuilder.buildPaginationParams(undefined, undefined);
expect(result).toEqual({
skip: 0,
take: 50,
});
});
it("should calculate skip based on page and limit", () => {
const result = QueryBuilder.buildPaginationParams(2, 20);
expect(result).toEqual({
skip: 20,
take: 20,
});
});
it("should handle page 1", () => {
const result = QueryBuilder.buildPaginationParams(1, 25);
expect(result).toEqual({
skip: 0,
take: 25,
});
});
it("should handle large page numbers", () => {
const result = QueryBuilder.buildPaginationParams(10, 50);
expect(result).toEqual({
skip: 450,
take: 50,
});
});
});
});

View File

@@ -0,0 +1,175 @@
import { SortOrder } from "../dto";
/**
* Utility class for building Prisma query filters
* Provides reusable methods for common query operations
*/
export class QueryBuilder {
/**
* Build a full-text search filter across multiple fields
* @param search - Search query string
* @param fields - Fields to search in
* @returns Prisma where clause with OR conditions
*/
static buildSearchFilter(
search: string | undefined,
fields: string[]
): Record<string, any> {
if (!search || search.trim() === "") {
return {};
}
const trimmedSearch = search.trim();
return {
OR: fields.map((field) => ({
[field]: {
contains: trimmedSearch,
mode: "insensitive" as const,
},
})),
};
}
/**
* Build sort order configuration
* Supports single or multi-field sorting with custom order per field
* @param sortBy - Field(s) to sort by (comma-separated)
* @param sortOrder - Default sort order
* @param defaultSort - Fallback sort order if sortBy is undefined
* @returns Prisma orderBy clause
*/
static buildSortOrder(
sortBy?: string,
sortOrder?: SortOrder,
defaultSort?: Record<string, string>
): Record<string, string> | Record<string, string>[] {
if (!sortBy) {
return defaultSort || { createdAt: "desc" };
}
const fields = sortBy.split(",").map((f) => f.trim());
if (fields.length === 1) {
// Check if field has custom order (e.g., "priority:asc")
const [field, customOrder] = fields[0].split(":");
return {
[field]: customOrder || sortOrder || SortOrder.DESC,
};
}
// Multi-field sorting
return fields.map((field) => {
const [fieldName, customOrder] = field.split(":");
return {
[fieldName]: customOrder || sortOrder || SortOrder.DESC,
};
});
}
/**
* Build date range filter
* @param field - Date field name
* @param from - Start date
* @param to - End date
* @returns Prisma where clause with date range
*/
static buildDateRangeFilter(
field: string,
from?: Date,
to?: Date
): Record<string, any> {
if (!from && !to) {
return {};
}
const filter: Record<string, any> = {};
if (from || to) {
filter[field] = {};
if (from) {
filter[field].gte = from;
}
if (to) {
filter[field].lte = to;
}
}
return filter;
}
/**
* Build IN filter for multi-select fields
* @param field - Field name
* @param values - Array of values or single value
* @returns Prisma where clause with IN condition
*/
static buildInFilter<T>(
field: string,
values?: T | T[]
): Record<string, any> {
if (!values) {
return {};
}
const valueArray = Array.isArray(values) ? values : [values];
if (valueArray.length === 0) {
return {};
}
return {
[field]: { in: valueArray },
};
}
/**
* Build pagination parameters
* @param page - Page number (1-indexed)
* @param limit - Items per page
* @returns Prisma skip and take parameters
*/
static buildPaginationParams(
page?: number,
limit?: number
): { skip: number; take: number } {
const actualPage = page || 1;
const actualLimit = limit || 50;
return {
skip: (actualPage - 1) * actualLimit,
take: actualLimit,
};
}
/**
* Build pagination metadata
* @param total - Total count of items
* @param page - Current page
* @param limit - Items per page
* @returns Pagination metadata object
*/
static buildPaginationMeta(
total: number,
page: number,
limit: number
): {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
} {
const totalPages = Math.ceil(total / limit);
return {
total,
page,
limit,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
};
}
}