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:
1
apps/api/src/common/utils/index.ts
Normal file
1
apps/api/src/common/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./query-builder";
|
||||
183
apps/api/src/common/utils/query-builder.spec.ts
Normal file
183
apps/api/src/common/utils/query-builder.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
175
apps/api/src/common/utils/query-builder.ts
Normal file
175
apps/api/src/common/utils/query-builder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user