Release: CI/CD Pipeline & Architecture Updates #177
@@ -3,11 +3,12 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { KnowledgeController } from "./knowledge.controller";
|
||||
import { LinkResolutionService } from "./services/link-resolution.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [KnowledgeController],
|
||||
providers: [KnowledgeService],
|
||||
exports: [KnowledgeService],
|
||||
providers: [KnowledgeService, LinkResolutionService],
|
||||
exports: [KnowledgeService, LinkResolutionService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
|
||||
2
apps/api/src/knowledge/services/index.ts
Normal file
2
apps/api/src/knowledge/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LinkResolutionService } from "./link-resolution.service";
|
||||
export type { ResolvedEntry } from "./link-resolution.service";
|
||||
406
apps/api/src/knowledge/services/link-resolution.service.spec.ts
Normal file
406
apps/api/src/knowledge/services/link-resolution.service.spec.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { LinkResolutionService } from "./link-resolution.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
describe("LinkResolutionService", () => {
|
||||
let service: LinkResolutionService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const workspaceId = "workspace-123";
|
||||
|
||||
const mockEntries = [
|
||||
{
|
||||
id: "entry-1",
|
||||
workspaceId,
|
||||
slug: "typescript-guide",
|
||||
title: "TypeScript Guide",
|
||||
content: "...",
|
||||
contentHtml: "...",
|
||||
summary: null,
|
||||
status: "PUBLISHED",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
},
|
||||
{
|
||||
id: "entry-2",
|
||||
workspaceId,
|
||||
slug: "react-hooks",
|
||||
title: "React Hooks",
|
||||
content: "...",
|
||||
contentHtml: "...",
|
||||
summary: null,
|
||||
status: "PUBLISHED",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
},
|
||||
{
|
||||
id: "entry-3",
|
||||
workspaceId,
|
||||
slug: "react-hooks-advanced",
|
||||
title: "React Hooks Advanced",
|
||||
content: "...",
|
||||
contentHtml: "...",
|
||||
summary: null,
|
||||
status: "PUBLISHED",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
},
|
||||
];
|
||||
|
||||
const mockPrismaService = {
|
||||
knowledgeEntry: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LinkResolutionService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LinkResolutionService>(LinkResolutionService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("resolveLink", () => {
|
||||
describe("Exact title match", () => {
|
||||
it("should resolve link by exact title match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"TypeScript Guide"
|
||||
);
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
||||
{
|
||||
where: {
|
||||
workspaceId,
|
||||
title: "TypeScript Guide",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should be case-sensitive for exact title match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Slug match", () => {
|
||||
it("should resolve link by slug", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript-guide"
|
||||
);
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith(
|
||||
{
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug: "typescript-guide",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should prioritize exact title match over slug match", async () => {
|
||||
// If exact title matches, slug should not be checked
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"TypeScript Guide"
|
||||
);
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fuzzy title match", () => {
|
||||
it("should resolve link by case-insensitive fuzzy match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
||||
mockEntries[0],
|
||||
]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: {
|
||||
mode: "insensitive",
|
||||
equals: "typescript guide",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when fuzzy match finds multiple results", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
||||
mockEntries[1],
|
||||
mockEntries[2],
|
||||
]);
|
||||
|
||||
const result = await service.resolveLink(workspaceId, "react hooks");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when no match is found", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
"Non-existent Entry"
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace scoping", () => {
|
||||
it("should only resolve links within the specified workspace", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
await service.resolveLink("different-workspace", "TypeScript Guide");
|
||||
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
workspaceId: "different-workspace",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty string input", async () => {
|
||||
const result = await service.resolveLink(workspaceId, "");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle null input", async () => {
|
||||
const result = await service.resolveLink(workspaceId, null as any);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle whitespace-only input", async () => {
|
||||
const result = await service.resolveLink(workspaceId, " ");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trim whitespace from target before resolving", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
|
||||
const result = await service.resolveLink(
|
||||
workspaceId,
|
||||
" TypeScript Guide "
|
||||
);
|
||||
|
||||
expect(result).toBe("entry-1");
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
title: "TypeScript Guide",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLinks", () => {
|
||||
it("should resolve multiple links in batch", async () => {
|
||||
// First link: "TypeScript Guide" -> exact title match
|
||||
// Second link: "react-hooks" -> slug match
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockImplementation(
|
||||
async ({ where }: any) => {
|
||||
if (where.title === "TypeScript Guide") {
|
||||
return mockEntries[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockImplementation(
|
||||
async ({ where }: any) => {
|
||||
if (where.workspaceId_slug?.slug === "react-hooks") {
|
||||
return mockEntries[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
const targets = ["TypeScript Guide", "react-hooks"];
|
||||
const result = await service.resolveLinks(workspaceId, targets);
|
||||
|
||||
expect(result).toEqual({
|
||||
"TypeScript Guide": "entry-1",
|
||||
"react-hooks": "entry-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
const result = await service.resolveLinks(workspaceId, []);
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle unresolved links", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.resolveLinks(workspaceId, [
|
||||
"Non-existent",
|
||||
"Another-Non-existent",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"Non-existent": null,
|
||||
"Another-Non-existent": null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should deduplicate targets", async () => {
|
||||
mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(
|
||||
mockEntries[0]
|
||||
);
|
||||
|
||||
const result = await service.resolveLinks(workspaceId, [
|
||||
"TypeScript Guide",
|
||||
"TypeScript Guide",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"TypeScript Guide": "entry-1",
|
||||
});
|
||||
// Should only be called once for the deduplicated target
|
||||
expect(mockPrismaService.knowledgeEntry.findFirst).toHaveBeenCalledTimes(
|
||||
1
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAmbiguousMatches", () => {
|
||||
it("should return multiple entries that match case-insensitively", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
||||
{ id: "entry-2", title: "React Hooks" },
|
||||
{ id: "entry-3", title: "React Hooks Advanced" },
|
||||
]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"react hooks"
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual([
|
||||
{ id: "entry-2", title: "React Hooks" },
|
||||
{ id: "entry-3", title: "React Hooks Advanced" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no matches found", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"Non-existent"
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return single entry if only one match", async () => {
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([
|
||||
{ id: "entry-1", title: "TypeScript Guide" },
|
||||
]);
|
||||
|
||||
const result = await service.getAmbiguousMatches(
|
||||
workspaceId,
|
||||
"typescript guide"
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal file
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
/**
|
||||
* Represents a knowledge entry that matches a link target
|
||||
*/
|
||||
export interface ResolvedEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for resolving wiki-style links to knowledge entries
|
||||
*
|
||||
* Resolution strategy (in order of priority):
|
||||
* 1. Exact title match (case-sensitive)
|
||||
* 2. Slug match
|
||||
* 3. Fuzzy title match (case-insensitive)
|
||||
*
|
||||
* Supports workspace scoping via RLS
|
||||
*/
|
||||
@Injectable()
|
||||
export class LinkResolutionService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Resolve a single link target to a knowledge entry ID
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param target - The link target (title or slug)
|
||||
* @returns The entry ID if resolved, null if not found or ambiguous
|
||||
*/
|
||||
async resolveLink(
|
||||
workspaceId: string,
|
||||
target: string
|
||||
): Promise<string | null> {
|
||||
// Validate input
|
||||
if (!target || typeof target !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
// Reject empty or whitespace-only strings
|
||||
if (trimmedTarget.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Try exact title match (case-sensitive)
|
||||
const exactMatch = await this.prisma.knowledgeEntry.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: trimmedTarget,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch.id;
|
||||
}
|
||||
|
||||
// 2. Try slug match
|
||||
const slugMatch = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: {
|
||||
workspaceId_slug: {
|
||||
workspaceId,
|
||||
slug: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (slugMatch) {
|
||||
return slugMatch.id;
|
||||
}
|
||||
|
||||
// 3. Try fuzzy match (case-insensitive)
|
||||
const fuzzyMatches = await this.prisma.knowledgeEntry.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: {
|
||||
mode: "insensitive",
|
||||
equals: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Return null if no matches or multiple matches (ambiguous)
|
||||
if (fuzzyMatches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fuzzyMatches.length > 1) {
|
||||
// Ambiguous match - multiple entries with similar titles
|
||||
return null;
|
||||
}
|
||||
|
||||
return fuzzyMatches[0].id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple link targets in batch
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param targets - Array of link targets
|
||||
* @returns Map of target to resolved entry ID (null if not found)
|
||||
*/
|
||||
async resolveLinks(
|
||||
workspaceId: string,
|
||||
targets: string[]
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result: Record<string, string | null> = {};
|
||||
|
||||
// Deduplicate targets
|
||||
const uniqueTargets = Array.from(new Set(targets));
|
||||
|
||||
// Resolve each target
|
||||
for (const target of uniqueTargets) {
|
||||
const resolved = await this.resolveLink(workspaceId, target);
|
||||
result[target] = resolved;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all entries that could match a link target (for disambiguation UI)
|
||||
*
|
||||
* @param workspaceId - The workspace scope
|
||||
* @param target - The link target
|
||||
* @returns Array of matching entries
|
||||
*/
|
||||
async getAmbiguousMatches(
|
||||
workspaceId: string,
|
||||
target: string
|
||||
): Promise<ResolvedEntry[]> {
|
||||
const trimmedTarget = target.trim();
|
||||
|
||||
if (trimmedTarget.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches = await this.prisma.knowledgeEntry.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
title: {
|
||||
mode: "insensitive",
|
||||
equals: trimmedTarget,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user