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 @@
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,
};
}
}