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:
@@ -0,0 +1,31 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FormalityLevel" AS ENUM ('VERY_CASUAL', 'CASUAL', 'NEUTRAL', 'FORMAL', 'VERY_FORMAL');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "personalities" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"tone" TEXT NOT NULL,
|
||||
"formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL',
|
||||
"system_prompt_template" TEXT NOT NULL,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL,
|
||||
|
||||
CONSTRAINT "personalities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "personalities_workspace_id_idx" ON "personalities"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "personalities_workspace_id_is_default_idx" ON "personalities"("workspace_id", "is_default");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "personalities_workspace_id_name_key" ON "personalities"("workspace_id", "name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "personalities" ADD CONSTRAINT "personalities_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the `personalities` table. If the table is not empty, all the data it contains will be lost.
|
||||
- Added the required column `display_text` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `position_end` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `position_start` to the `knowledge_links` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "personalities" DROP CONSTRAINT "personalities_workspace_id_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "knowledge_links_source_id_target_id_key";
|
||||
|
||||
-- AlterTable: Add new columns with temporary defaults for existing records
|
||||
ALTER TABLE "knowledge_links"
|
||||
ADD COLUMN "display_text" TEXT DEFAULT '',
|
||||
ADD COLUMN "position_end" INTEGER DEFAULT 0,
|
||||
ADD COLUMN "position_start" INTEGER DEFAULT 0,
|
||||
ADD COLUMN "resolved" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "target_id" DROP NOT NULL;
|
||||
|
||||
-- Update existing records: set display_text to link_text and resolved to true if target exists
|
||||
UPDATE "knowledge_links" SET "display_text" = "link_text" WHERE "display_text" = '';
|
||||
UPDATE "knowledge_links" SET "resolved" = true WHERE "target_id" IS NOT NULL;
|
||||
|
||||
-- Remove defaults for new records
|
||||
ALTER TABLE "knowledge_links"
|
||||
ALTER COLUMN "display_text" DROP DEFAULT,
|
||||
ALTER COLUMN "position_end" DROP DEFAULT,
|
||||
ALTER COLUMN "position_start" DROP DEFAULT;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "personalities";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "FormalityLevel";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "knowledge_links_source_id_resolved_idx" ON "knowledge_links"("source_id", "resolved");
|
||||
170
apps/api/src/common/dto/base-filter.dto.spec.ts
Normal file
170
apps/api/src/common/dto/base-filter.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
apps/api/src/common/dto/base-filter.dto.ts
Normal file
82
apps/api/src/common/dto/base-filter.dto.ts
Normal 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;
|
||||
}
|
||||
1
apps/api/src/common/dto/index.ts
Normal file
1
apps/api/src/common/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./base-filter.dto";
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
|
||||
/**
|
||||
* Controller for knowledge entry endpoints
|
||||
@@ -24,7 +25,10 @@ import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
@Controller("knowledge/entries")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly knowledgeService: KnowledgeService) {}
|
||||
constructor(
|
||||
private readonly knowledgeService: KnowledgeService,
|
||||
private readonly linkSync: LinkSyncService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries
|
||||
@@ -100,4 +104,32 @@ export class KnowledgeController {
|
||||
await this.knowledgeService.remove(workspaceId, slug, user.id);
|
||||
return { message: "Entry archived successfully" };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug/backlinks
|
||||
* Get all backlinks for an entry
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug/backlinks")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getBacklinks(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string
|
||||
) {
|
||||
// First find the entry to get its ID
|
||||
const entry = await this.knowledgeService.findOne(workspaceId, slug);
|
||||
|
||||
// Get backlinks
|
||||
const backlinks = await this.linkSync.getBacklinks(entry.id);
|
||||
|
||||
return {
|
||||
entry: {
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
},
|
||||
backlinks,
|
||||
count: backlinks.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ import type {
|
||||
PaginatedEntries,
|
||||
} from "./entities/knowledge-entry.entity";
|
||||
import { renderMarkdown } from "./utils/markdown";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
|
||||
/**
|
||||
* Service for managing knowledge entries
|
||||
*/
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly linkSync: LinkSyncService
|
||||
) {}
|
||||
|
||||
|
||||
/**
|
||||
@@ -225,6 +229,9 @@ export class KnowledgeService {
|
||||
throw new Error("Failed to create entry");
|
||||
}
|
||||
|
||||
// Sync wiki links after entry creation
|
||||
await this.linkSync.syncLinks(workspaceId, result.id, createDto.content);
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
workspaceId: result.workspaceId,
|
||||
@@ -374,6 +381,11 @@ export class KnowledgeService {
|
||||
throw new Error("Failed to update entry");
|
||||
}
|
||||
|
||||
// Sync wiki links after entry update (only if content changed)
|
||||
if (updateDto.content !== undefined) {
|
||||
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
workspaceId: result.workspaceId,
|
||||
|
||||
410
apps/api/src/knowledge/services/link-sync.service.spec.ts
Normal file
410
apps/api/src/knowledge/services/link-sync.service.spec.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { LinkSyncService } from "./link-sync.service";
|
||||
import { LinkResolutionService } from "./link-resolution.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import * as wikiLinkParser from "../utils/wiki-link-parser";
|
||||
|
||||
// Mock the wiki-link parser
|
||||
vi.mock("../utils/wiki-link-parser");
|
||||
const mockParseWikiLinks = vi.mocked(wikiLinkParser.parseWikiLinks);
|
||||
|
||||
describe("LinkSyncService", () => {
|
||||
let service: LinkSyncService;
|
||||
let prisma: PrismaService;
|
||||
let linkResolver: LinkResolutionService;
|
||||
|
||||
const mockWorkspaceId = "workspace-1";
|
||||
const mockEntryId = "entry-1";
|
||||
const mockTargetId = "entry-2";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LinkSyncService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
knowledgeLink: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((fn) => fn(prisma)),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LinkResolutionService,
|
||||
useValue: {
|
||||
resolveLink: vi.fn(),
|
||||
resolveLinks: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LinkSyncService>(LinkSyncService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
linkResolver = module.get<LinkResolutionService>(LinkResolutionService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("syncLinks", () => {
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it("should parse wiki links from content", async () => {
|
||||
const content = "This is a [[Test Link]] in content";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[Test Link]]",
|
||||
target: "Test Link",
|
||||
displayText: "Test Link",
|
||||
start: 10,
|
||||
end: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(mockParseWikiLinks).toHaveBeenCalledWith(content);
|
||||
});
|
||||
|
||||
it("should create new links when parsing finds wiki links", async () => {
|
||||
const content = "This is a [[Test Link]] in content";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[Test Link]]",
|
||||
target: "Test Link",
|
||||
displayText: "Test Link",
|
||||
start: 10,
|
||||
end: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({
|
||||
id: "link-1",
|
||||
sourceId: mockEntryId,
|
||||
targetId: mockTargetId,
|
||||
linkText: "Test Link",
|
||||
displayText: "Test Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 25,
|
||||
resolved: true,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
sourceId: mockEntryId,
|
||||
targetId: mockTargetId,
|
||||
linkText: "Test Link",
|
||||
displayText: "Test Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 25,
|
||||
resolved: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create unresolved links when target cannot be found", async () => {
|
||||
const content = "This is a [[Nonexistent Link]] in content";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[Nonexistent Link]]",
|
||||
target: "Nonexistent Link",
|
||||
displayText: "Nonexistent Link",
|
||||
start: 10,
|
||||
end: 32,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(null);
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({
|
||||
id: "link-1",
|
||||
sourceId: mockEntryId,
|
||||
targetId: null,
|
||||
linkText: "Nonexistent Link",
|
||||
displayText: "Nonexistent Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 32,
|
||||
resolved: false,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
sourceId: mockEntryId,
|
||||
targetId: null,
|
||||
linkText: "Nonexistent Link",
|
||||
displayText: "Nonexistent Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 32,
|
||||
resolved: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle custom display text in links", async () => {
|
||||
const content = "This is a [[Target|Custom Display]] in content";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[Target|Custom Display]]",
|
||||
target: "Target",
|
||||
displayText: "Custom Display",
|
||||
start: 10,
|
||||
end: 35,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
sourceId: mockEntryId,
|
||||
targetId: mockTargetId,
|
||||
linkText: "Target",
|
||||
displayText: "Custom Display",
|
||||
positionStart: 10,
|
||||
positionEnd: 35,
|
||||
resolved: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete orphaned links not present in updated content", async () => {
|
||||
const content = "This is a [[New Link]] in content";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[New Link]]",
|
||||
target: "New Link",
|
||||
displayText: "New Link",
|
||||
start: 10,
|
||||
end: 22,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock existing link that should be removed
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([
|
||||
{
|
||||
id: "old-link-1",
|
||||
sourceId: mockEntryId,
|
||||
targetId: "old-target",
|
||||
linkText: "Old Link",
|
||||
displayText: "Old Link",
|
||||
positionStart: 5,
|
||||
positionEnd: 17,
|
||||
resolved: true,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
|
||||
vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceId: mockEntryId,
|
||||
id: {
|
||||
in: ["old-link-1"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty content by removing all links", async () => {
|
||||
const content = "";
|
||||
mockParseWikiLinks.mockReturnValue([]);
|
||||
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: mockEntryId,
|
||||
targetId: mockTargetId,
|
||||
linkText: "Link",
|
||||
displayText: "Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 18,
|
||||
resolved: true,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as any);
|
||||
|
||||
vi.spyOn(prisma.knowledgeLink, "deleteMany").mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
sourceId: mockEntryId,
|
||||
id: {
|
||||
in: ["link-1"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple links in content", async () => {
|
||||
const content = "Links: [[Link 1]] and [[Link 2]] and [[Link 3]]";
|
||||
mockParseWikiLinks.mockReturnValue([
|
||||
{
|
||||
raw: "[[Link 1]]",
|
||||
target: "Link 1",
|
||||
displayText: "Link 1",
|
||||
start: 7,
|
||||
end: 17,
|
||||
},
|
||||
{
|
||||
raw: "[[Link 2]]",
|
||||
target: "Link 2",
|
||||
displayText: "Link 2",
|
||||
start: 22,
|
||||
end: 32,
|
||||
},
|
||||
{
|
||||
raw: "[[Link 3]]",
|
||||
target: "Link 3",
|
||||
displayText: "Link 3",
|
||||
start: 37,
|
||||
end: 47,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.spyOn(linkResolver, "resolveLink").mockResolvedValue(mockTargetId);
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
vi.spyOn(prisma.knowledgeLink, "create").mockResolvedValue({} as any);
|
||||
|
||||
await service.syncLinks(mockWorkspaceId, mockEntryId, content);
|
||||
|
||||
expect(prisma.knowledgeLink.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBacklinks", () => {
|
||||
it("should return all backlinks for an entry", async () => {
|
||||
const mockBacklinks = [
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: "source-1",
|
||||
targetId: mockEntryId,
|
||||
linkText: "Link Text",
|
||||
displayText: "Link Text",
|
||||
positionStart: 10,
|
||||
positionEnd: 25,
|
||||
resolved: true,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
source: {
|
||||
id: "source-1",
|
||||
title: "Source Entry",
|
||||
slug: "source-entry",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockBacklinks as any);
|
||||
|
||||
const result = await service.getBacklinks(mockEntryId);
|
||||
|
||||
expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
targetId: mockEntryId,
|
||||
resolved: true,
|
||||
},
|
||||
include: {
|
||||
source: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockBacklinks);
|
||||
});
|
||||
|
||||
it("should return empty array when no backlinks exist", async () => {
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue([]);
|
||||
|
||||
const result = await service.getBacklinks(mockEntryId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUnresolvedLinks", () => {
|
||||
it("should return all unresolved links for a workspace", async () => {
|
||||
const mockUnresolvedLinks = [
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: mockEntryId,
|
||||
targetId: null,
|
||||
linkText: "Unresolved Link",
|
||||
displayText: "Unresolved Link",
|
||||
positionStart: 10,
|
||||
positionEnd: 29,
|
||||
resolved: false,
|
||||
context: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.spyOn(prisma.knowledgeLink, "findMany").mockResolvedValue(mockUnresolvedLinks as any);
|
||||
|
||||
const result = await service.getUnresolvedLinks(mockWorkspaceId);
|
||||
|
||||
expect(prisma.knowledgeLink.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
source: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
resolved: false,
|
||||
},
|
||||
include: {
|
||||
source: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUnresolvedLinks);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
apps/api/src/knowledge/services/link-sync.service.ts
Normal file
201
apps/api/src/knowledge/services/link-sync.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { LinkResolutionService } from "./link-resolution.service";
|
||||
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
|
||||
|
||||
/**
|
||||
* Represents a backlink to a knowledge entry
|
||||
*/
|
||||
export interface Backlink {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
linkText: string;
|
||||
displayText: string;
|
||||
positionStart: number;
|
||||
positionEnd: number;
|
||||
resolved: boolean;
|
||||
context: string | null;
|
||||
createdAt: Date;
|
||||
source: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an unresolved wiki link
|
||||
*/
|
||||
export interface UnresolvedLink {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string | null;
|
||||
linkText: string;
|
||||
displayText: string;
|
||||
positionStart: number;
|
||||
positionEnd: number;
|
||||
resolved: boolean;
|
||||
context: string | null;
|
||||
createdAt: Date;
|
||||
source: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for synchronizing wiki-style links in knowledge entries
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Parse content for wiki links
|
||||
* - Resolve links to knowledge entries
|
||||
* - Store/update link records
|
||||
* - Handle orphaned links
|
||||
*/
|
||||
@Injectable()
|
||||
export class LinkSyncService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly linkResolver: LinkResolutionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sync links for a knowledge entry
|
||||
* Parses content, resolves links, and updates the database
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param entryId - The entry being updated
|
||||
* @param content - The markdown content to parse
|
||||
*/
|
||||
async syncLinks(
|
||||
workspaceId: string,
|
||||
entryId: string,
|
||||
content: string
|
||||
): Promise<void> {
|
||||
// Parse wiki links from content
|
||||
const parsedLinks = parseWikiLinks(content);
|
||||
|
||||
// Get existing links for this entry
|
||||
const existingLinks = await this.prisma.knowledgeLink.findMany({
|
||||
where: {
|
||||
sourceId: entryId,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve all parsed links
|
||||
const linkCreations: Array<{
|
||||
sourceId: string;
|
||||
targetId: string | null;
|
||||
linkText: string;
|
||||
displayText: string;
|
||||
positionStart: number;
|
||||
positionEnd: number;
|
||||
resolved: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const link of parsedLinks) {
|
||||
const targetId = await this.linkResolver.resolveLink(
|
||||
workspaceId,
|
||||
link.target
|
||||
);
|
||||
|
||||
linkCreations.push({
|
||||
sourceId: entryId,
|
||||
targetId: targetId,
|
||||
linkText: link.target,
|
||||
displayText: link.displayText,
|
||||
positionStart: link.start,
|
||||
positionEnd: link.end,
|
||||
resolved: targetId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which existing links to keep/delete
|
||||
// We'll use a simple strategy: delete all existing and recreate
|
||||
// (In production, you might want to diff and only update changed links)
|
||||
const existingLinkIds = existingLinks.map((link) => link.id);
|
||||
|
||||
// Delete all existing links and create new ones in a transaction
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// Delete all existing links
|
||||
if (existingLinkIds.length > 0) {
|
||||
await tx.knowledgeLink.deleteMany({
|
||||
where: {
|
||||
sourceId: entryId,
|
||||
id: {
|
||||
in: existingLinkIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create new links
|
||||
for (const linkData of linkCreations) {
|
||||
await tx.knowledgeLink.create({
|
||||
data: linkData,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all backlinks for an entry
|
||||
* Returns entries that link TO this entry
|
||||
*
|
||||
* @param entryId - The target entry
|
||||
* @returns Array of backlinks with source entry information
|
||||
*/
|
||||
async getBacklinks(entryId: string): Promise<Backlink[]> {
|
||||
const backlinks = await this.prisma.knowledgeLink.findMany({
|
||||
where: {
|
||||
targetId: entryId,
|
||||
resolved: true,
|
||||
},
|
||||
include: {
|
||||
source: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return backlinks as Backlink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all unresolved links for a workspace
|
||||
* Useful for finding broken links or pages that need to be created
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @returns Array of unresolved links
|
||||
*/
|
||||
async getUnresolvedLinks(workspaceId: string): Promise<UnresolvedLink[]> {
|
||||
const unresolvedLinks = await this.prisma.knowledgeLink.findMany({
|
||||
where: {
|
||||
source: {
|
||||
workspaceId,
|
||||
},
|
||||
resolved: false,
|
||||
},
|
||||
include: {
|
||||
source: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return unresolvedLinks as UnresolvedLink[];
|
||||
}
|
||||
}
|
||||
43
apps/api/src/personalities/dto/create-personality.dto.ts
Normal file
43
apps/api/src/personalities/dto/create-personality.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { IsString, IsIn, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
export const FORMALITY_LEVELS = [
|
||||
"VERY_CASUAL",
|
||||
"CASUAL",
|
||||
"NEUTRAL",
|
||||
"FORMAL",
|
||||
"VERY_FORMAL",
|
||||
] as const;
|
||||
|
||||
export type FormalityLevel = (typeof FORMALITY_LEVELS)[number];
|
||||
|
||||
export class CreatePersonalityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
tone!: string;
|
||||
|
||||
@IsIn(FORMALITY_LEVELS)
|
||||
formalityLevel!: FormalityLevel;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPromptTemplate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
2
apps/api/src/personalities/dto/index.ts
Normal file
2
apps/api/src/personalities/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./create-personality.dto";
|
||||
export * from "./update-personality.dto";
|
||||
4
apps/api/src/personalities/dto/update-personality.dto.ts
Normal file
4
apps/api/src/personalities/dto/update-personality.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
import { CreatePersonalityDto } from "./create-personality.dto";
|
||||
|
||||
export class UpdatePersonalityDto extends PartialType(CreatePersonalityDto) {}
|
||||
15
apps/api/src/personalities/entities/personality.entity.ts
Normal file
15
apps/api/src/personalities/entities/personality.entity.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Personality as PrismaPersonality, FormalityLevel } from "@prisma/client";
|
||||
|
||||
export class Personality implements PrismaPersonality {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string;
|
||||
description!: string | null;
|
||||
tone!: string;
|
||||
formalityLevel!: FormalityLevel;
|
||||
systemPromptTemplate!: string;
|
||||
isDefault!: boolean;
|
||||
isActive!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
}
|
||||
157
apps/api/src/personalities/personalities.controller.spec.ts
Normal file
157
apps/api/src/personalities/personalities.controller.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
|
||||
describe("PersonalitiesController", () => {
|
||||
let controller: PersonalitiesController;
|
||||
let service: PersonalitiesService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: mockUserId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
};
|
||||
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Professional",
|
||||
description: "Professional communication style",
|
||||
tone: "professional",
|
||||
formalityLevel: "FORMAL" as const,
|
||||
systemPromptTemplate: "You are a professional assistant.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPersonalitiesService = {
|
||||
findAll: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
findDefault: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [PersonalitiesController],
|
||||
providers: [
|
||||
{
|
||||
provide: PersonalitiesService,
|
||||
useValue: mockPersonalitiesService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||
|
||||
const result = await controller.findAll(mockRequest as any);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, true);
|
||||
});
|
||||
|
||||
it("should filter by active status", async () => {
|
||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||
|
||||
await controller.findAll(mockRequest as any, false);
|
||||
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findOne(mockRequest as any, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findDefault(mockRequest as any);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "Casual",
|
||||
description: "Casual communication style",
|
||||
tone: "casual",
|
||||
formalityLevel: "CASUAL",
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
const newPersonality = { ...mockPersonality, ...createDto, id: "new-id" };
|
||||
mockPersonalitiesService.create.mockResolvedValue(newPersonality);
|
||||
|
||||
const result = await controller.create(mockRequest as any, createDto);
|
||||
|
||||
expect(result).toEqual(newPersonality);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
const updatedPersonality = { ...mockPersonality, ...updateDto };
|
||||
mockPersonalitiesService.update.mockResolvedValue(updatedPersonality);
|
||||
|
||||
const result = await controller.update(mockRequest as any, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toEqual(updatedPersonality);
|
||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPersonalitiesService.remove.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.remove(mockRequest as any, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
apps/api/src/personalities/personalities.controller.ts
Normal file
77
apps/api/src/personalities/personalities.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
@Controller("personalities")
|
||||
@UseGuards(AuthGuard)
|
||||
export class PersonalitiesController {
|
||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||
|
||||
/**
|
||||
* Get all personalities for the current workspace
|
||||
*/
|
||||
@Get()
|
||||
async findAll(
|
||||
@Req() req: any,
|
||||
@Query("isActive") isActive: boolean = true,
|
||||
): Promise<Personality[]> {
|
||||
return this.personalitiesService.findAll(req.workspaceId, isActive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default personality for the current workspace
|
||||
*/
|
||||
@Get("default")
|
||||
async findDefault(@Req() req: any): Promise<Personality> {
|
||||
return this.personalitiesService.findDefault(req.workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific personality by ID
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Req() req: any, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
@Post()
|
||||
async create(@Req() req: any, @Body() dto: CreatePersonalityDto): Promise<Personality> {
|
||||
return this.personalitiesService.create(req.workspaceId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
@Put(":id")
|
||||
async update(
|
||||
@Req() req: any,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePersonalityDto,
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
@Delete(":id")
|
||||
async remove(@Req() req: any, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.remove(req.workspaceId, id);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/personalities/personalities.module.ts
Normal file
13
apps/api/src/personalities/personalities.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [PersonalitiesController],
|
||||
providers: [PersonalitiesService],
|
||||
exports: [PersonalitiesService],
|
||||
})
|
||||
export class PersonalitiesModule {}
|
||||
255
apps/api/src/personalities/personalities.service.spec.ts
Normal file
255
apps/api/src/personalities/personalities.service.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||
|
||||
describe("PersonalitiesService", () => {
|
||||
let service: PersonalitiesService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Professional",
|
||||
description: "Professional communication style",
|
||||
tone: "professional",
|
||||
formalityLevel: "FORMAL" as const,
|
||||
systemPromptTemplate: "You are a professional assistant.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
personality: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PersonalitiesService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities for a workspace", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isActive: true },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by active status", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPersonality]);
|
||||
|
||||
await service.findAll(mockWorkspaceId, false);
|
||||
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isActive: false },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await service.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isActive: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when no default personality exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findDefault(mockWorkspaceId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "Casual",
|
||||
description: "Casual communication style",
|
||||
tone: "casual",
|
||||
formalityLevel: "CASUAL",
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
id: "new-personality-id",
|
||||
});
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
...createDto,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ConflictException when name already exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
|
||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should unset other defaults when creating a new default personality", async () => {
|
||||
const createDefaultDto = { ...createDto, isDefault: true };
|
||||
// First call to findFirst checks for name conflict (should be null)
|
||||
// Second call to findFirst finds the existing default personality
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(null) // No name conflict
|
||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: false,
|
||||
});
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDefaultDto,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, createDefaultDto);
|
||||
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
tone: "updated",
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
|
||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDto,
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when updating to existing name", async () => {
|
||||
const updateNameDto = { name: "Existing Name" };
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
id: "different-id",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto),
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.delete.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await service.remove(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.delete).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
156
apps/api/src/personalities/personalities.service.ts
Normal file
156
apps/api/src/personalities/personalities.service.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
@Injectable()
|
||||
export class PersonalitiesService {
|
||||
private readonly logger = new Logger(PersonalitiesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Find all personalities for a workspace
|
||||
*/
|
||||
async findAll(workspaceId: string, isActive: boolean = true): Promise<Personality[]> {
|
||||
return this.prisma.personality.findMany({
|
||||
where: { workspaceId, isActive },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific personality by ID
|
||||
*/
|
||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findUnique({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!personality) {
|
||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the default personality for a workspace
|
||||
*/
|
||||
async findDefault(workspaceId: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, isDefault: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!personality) {
|
||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||
// Check for duplicate name
|
||||
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 a default personality, unset other defaults
|
||||
if (dto.isDefault) {
|
||||
await this.unsetOtherDefaults(workspaceId);
|
||||
}
|
||||
|
||||
const personality = await this.prisma.personality.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
...dto,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdatePersonalityDto,
|
||||
): Promise<Personality> {
|
||||
// Check 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);
|
||||
}
|
||||
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
});
|
||||
|
||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
async remove(workspaceId: string, id: string): Promise<Personality> {
|
||||
// Check existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
const personality = await this.prisma.personality.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
this.logger.log(`Deleted personality ${id} from workspace ${workspaceId}`);
|
||||
return 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 && { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
|
||||
if (currentDefault) {
|
||||
await this.prisma.personality.update({
|
||||
where: { id: currentDefault.id },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
168
apps/api/src/tasks/dto/query-tasks.dto.spec.ts
Normal file
168
apps/api/src/tasks/dto/query-tasks.dto.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { QueryTasksDto } from "./query-tasks.dto";
|
||||
import { TaskStatus, TaskPriority } from "@prisma/client";
|
||||
import { SortOrder } from "../../common/dto";
|
||||
|
||||
describe("QueryTasksDto", () => {
|
||||
const validWorkspaceId = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
it("should accept valid workspaceId", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should reject invalid workspaceId", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: "not-a-uuid",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e => e.property === "workspaceId")).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid status filter", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.status)).toBe(true);
|
||||
expect(dto.status).toEqual([TaskStatus.IN_PROGRESS]);
|
||||
});
|
||||
|
||||
it("should accept multiple status filters", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
status: [TaskStatus.IN_PROGRESS, TaskStatus.NOT_STARTED],
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.status)).toBe(true);
|
||||
expect(dto.status).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should accept valid priority filter", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
priority: TaskPriority.HIGH,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.priority)).toBe(true);
|
||||
expect(dto.priority).toEqual([TaskPriority.HIGH]);
|
||||
});
|
||||
|
||||
it("should accept multiple priority filters", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
priority: [TaskPriority.HIGH, TaskPriority.LOW],
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.priority)).toBe(true);
|
||||
expect(dto.priority).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should accept search parameter", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
search: "test task",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(dto.search).toBe("test task");
|
||||
});
|
||||
|
||||
it("should accept sortBy parameter", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
sortBy: "priority,dueDate",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(dto.sortBy).toBe("priority,dueDate");
|
||||
});
|
||||
|
||||
it("should accept sortOrder parameter", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
sortOrder: SortOrder.ASC,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(dto.sortOrder).toBe(SortOrder.ASC);
|
||||
});
|
||||
|
||||
it("should accept domainId filter", async () => {
|
||||
const domainId = "123e4567-e89b-12d3-a456-426614174001";
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
domainId,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.domainId)).toBe(true);
|
||||
expect(dto.domainId).toEqual([domainId]);
|
||||
});
|
||||
|
||||
it("should accept multiple domainId filters", async () => {
|
||||
const domainIds = [
|
||||
"123e4567-e89b-12d3-a456-426614174001",
|
||||
"123e4567-e89b-12d3-a456-426614174002",
|
||||
];
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
domainId: domainIds,
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
expect(Array.isArray(dto.domainId)).toBe(true);
|
||||
expect(dto.domainId).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should accept date range filters", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
dueDateFrom: "2024-01-01T00:00:00Z",
|
||||
dueDateTo: "2024-12-31T23:59:59Z",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should accept all filters combined", async () => {
|
||||
const dto = plainToClass(QueryTasksDto, {
|
||||
workspaceId: validWorkspaceId,
|
||||
status: [TaskStatus.IN_PROGRESS, TaskStatus.NOT_STARTED],
|
||||
priority: [TaskPriority.HIGH, TaskPriority.MEDIUM],
|
||||
search: "urgent task",
|
||||
sortBy: "priority,dueDate",
|
||||
sortOrder: SortOrder.ASC,
|
||||
page: 2,
|
||||
limit: 25,
|
||||
dueDateFrom: "2024-01-01T00:00:00Z",
|
||||
dueDateTo: "2024-12-31T23:59:59Z",
|
||||
});
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user