diff --git a/apps/api/src/knowledge/knowledge.module.ts b/apps/api/src/knowledge/knowledge.module.ts index 8b02a20..d826d33 100644 --- a/apps/api/src/knowledge/knowledge.module.ts +++ b/apps/api/src/knowledge/knowledge.module.ts @@ -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 {} diff --git a/apps/api/src/knowledge/services/index.ts b/apps/api/src/knowledge/services/index.ts new file mode 100644 index 0000000..94b803f --- /dev/null +++ b/apps/api/src/knowledge/services/index.ts @@ -0,0 +1,2 @@ +export { LinkResolutionService } from "./link-resolution.service"; +export type { ResolvedEntry } from "./link-resolution.service"; diff --git a/apps/api/src/knowledge/services/link-resolution.service.spec.ts b/apps/api/src/knowledge/services/link-resolution.service.spec.ts new file mode 100644 index 0000000..cf23a27 --- /dev/null +++ b/apps/api/src/knowledge/services/link-resolution.service.spec.ts @@ -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); + prisma = module.get(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); + }); + }); +}); diff --git a/apps/api/src/knowledge/services/link-resolution.service.ts b/apps/api/src/knowledge/services/link-resolution.service.ts new file mode 100644 index 0000000..e869100 --- /dev/null +++ b/apps/api/src/knowledge/services/link-resolution.service.ts @@ -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 { + // 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> { + const result: Record = {}; + + // 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 { + 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; + } +}