Merge feature/know-008-link-resolution (#60) into develop

Implements link resolution service for Knowledge Module:
- Three-tier resolution (exact title, slug, fuzzy)
- Workspace-scoped (RLS compliant)
- Batch processing with deduplication
- 19 tests, 100% coverage
This commit is contained in:
Jason Woltje
2026-01-29 17:51:26 -06:00
4 changed files with 579 additions and 2 deletions

View File

@@ -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 {}

View File

@@ -0,0 +1,2 @@
export { LinkResolutionService } from "./link-resolution.service";
export type { ResolvedEntry } from "./link-resolution.service";

View 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);
});
});
});

View 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;
}
}