import { Injectable } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser"; /** * Represents a knowledge entry that matches a link target */ export interface ResolvedEntry { id: string; title: string; } /** * Represents a resolved wiki link with entry information */ export interface ResolvedLink { /** The parsed wiki link */ link: WikiLink; /** The resolved entry ID, or null if not found */ entryId: string | null; } /** * Represents a backlink - an entry that links to a target entry */ export interface Backlink { /** The source entry ID */ sourceId: string; /** The source entry title */ sourceTitle: string; /** The source entry slug */ sourceSlug: string; /** The link text used to reference the target */ linkText: string; /** The display text shown for the link */ displayText: 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 the single match const match = fuzzyMatches[0]; return match ? match.id : null; } /** * 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; } /** * Parse wiki links from content and resolve them to knowledge entries * * @param content - The markdown content containing wiki links * @param workspaceId - The workspace scope for resolution * @returns Array of resolved links with entry IDs (or null if not found) */ async resolveLinksFromContent(content: string, workspaceId: string): Promise { // Parse wiki links from content const parsedLinks = parseWikiLinks(content); if (parsedLinks.length === 0) { return []; } // Resolve each link const resolvedLinks: ResolvedLink[] = []; for (const link of parsedLinks) { const entryId = await this.resolveLink(workspaceId, link.target); resolvedLinks.push({ link, entryId, }); } return resolvedLinks; } /** * Get all entries that link TO a specific entry (backlinks) * * @param entryId - The target entry ID * @returns Array of backlinks with source entry information */ async getBacklinks(entryId: string): Promise { // Find all links where this entry is the target const links = await this.prisma.knowledgeLink.findMany({ where: { targetId: entryId, resolved: true, }, include: { source: { select: { id: true, title: true, slug: true, }, }, }, orderBy: { createdAt: "desc", }, }); return links.map((link) => ({ sourceId: link.source.id, sourceTitle: link.source.title, sourceSlug: link.source.slug, linkText: link.linkText, displayText: link.displayText, })); } }