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