- Create LinkResolutionService with workspace-scoped link resolution - Resolve links by: exact title match, slug match, fuzzy title match - Handle ambiguous matches (return null if multiple matches) - Support batch link resolution with deduplication - Comprehensive test suite with 19 tests, all passing - 100% coverage of public methods - Integrate service with KnowledgeModule Closes #60 (KNOW-008)
169 lines
3.8 KiB
TypeScript
169 lines
3.8 KiB
TypeScript
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;
|
|
}
|
|
}
|