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:
@@ -3,11 +3,12 @@ import { PrismaModule } from "../prisma/prisma.module";
|
|||||||
import { AuthModule } from "../auth/auth.module";
|
import { AuthModule } from "../auth/auth.module";
|
||||||
import { KnowledgeService } from "./knowledge.service";
|
import { KnowledgeService } from "./knowledge.service";
|
||||||
import { KnowledgeController } from "./knowledge.controller";
|
import { KnowledgeController } from "./knowledge.controller";
|
||||||
|
import { LinkResolutionService } from "./services/link-resolution.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, AuthModule],
|
imports: [PrismaModule, AuthModule],
|
||||||
controllers: [KnowledgeController],
|
controllers: [KnowledgeController],
|
||||||
providers: [KnowledgeService],
|
providers: [KnowledgeService, LinkResolutionService],
|
||||||
exports: [KnowledgeService],
|
exports: [KnowledgeService, LinkResolutionService],
|
||||||
})
|
})
|
||||||
export class KnowledgeModule {}
|
export class KnowledgeModule {}
|
||||||
|
|||||||
2
apps/api/src/knowledge/services/index.ts
Normal file
2
apps/api/src/knowledge/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { LinkResolutionService } from "./link-resolution.service";
|
||||||
|
export type { ResolvedEntry } from "./link-resolution.service";
|
||||||
406
apps/api/src/knowledge/services/link-resolution.service.spec.ts
Normal file
406
apps/api/src/knowledge/services/link-resolution.service.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal file
168
apps/api/src/knowledge/services/link-resolution.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user