All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
944 lines
25 KiB
TypeScript
944 lines
25 KiB
TypeScript
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
|
import { EntryStatus, Prisma } from "@prisma/client";
|
|
import slugify from "slugify";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import type { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
|
import type { KnowledgeEntryWithTags, PaginatedEntries } from "./entities/knowledge-entry.entity";
|
|
import type {
|
|
KnowledgeEntryVersionWithAuthor,
|
|
PaginatedVersions,
|
|
} from "./entities/knowledge-entry-version.entity";
|
|
import { renderMarkdown } from "./utils/markdown";
|
|
import { LinkSyncService } from "./services/link-sync.service";
|
|
import { KnowledgeCacheService } from "./services/cache.service";
|
|
import { EmbeddingService } from "./services/embedding.service";
|
|
import { OllamaEmbeddingService } from "./services/ollama-embedding.service";
|
|
import { EmbeddingQueueService } from "./queues/embedding-queue.service";
|
|
|
|
/**
|
|
* Service for managing knowledge entries
|
|
*/
|
|
@Injectable()
|
|
export class KnowledgeService {
|
|
private readonly logger = new Logger(KnowledgeService.name);
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly linkSync: LinkSyncService,
|
|
private readonly cache: KnowledgeCacheService,
|
|
private readonly embedding: EmbeddingService,
|
|
private readonly ollamaEmbedding: OllamaEmbeddingService,
|
|
private readonly embeddingQueue: EmbeddingQueueService
|
|
) {}
|
|
|
|
/**
|
|
* Get all entries for a workspace (paginated and filterable)
|
|
*/
|
|
async findAll(workspaceId: string, query: EntryQueryDto): Promise<PaginatedEntries> {
|
|
const page = query.page ?? 1;
|
|
const limit = query.limit ?? 20;
|
|
const skip = (page - 1) * limit;
|
|
|
|
// Build where clause
|
|
const where: Prisma.KnowledgeEntryWhereInput = {
|
|
workspaceId,
|
|
};
|
|
|
|
if (query.status) {
|
|
where.status = query.status;
|
|
}
|
|
|
|
if (query.visibility) {
|
|
where.visibility = query.visibility;
|
|
}
|
|
|
|
if (query.tag) {
|
|
where.tags = {
|
|
some: {
|
|
tag: {
|
|
slug: query.tag,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (query.search) {
|
|
where.OR = [
|
|
{ title: { contains: query.search, mode: "insensitive" } },
|
|
{ content: { contains: query.search, mode: "insensitive" } },
|
|
];
|
|
}
|
|
|
|
// Build orderBy
|
|
const sortField = query.sortBy ?? "updatedAt";
|
|
const sortDirection = query.sortOrder ?? "desc";
|
|
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
|
[sortField]: sortDirection,
|
|
};
|
|
|
|
// Get total count
|
|
const total = await this.prisma.knowledgeEntry.count({ where });
|
|
|
|
// Get entries
|
|
const entries = await this.prisma.knowledgeEntry.findMany({
|
|
where,
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy,
|
|
skip,
|
|
take: limit,
|
|
});
|
|
|
|
// Transform to response format
|
|
const data: KnowledgeEntryWithTags[] = entries.map((entry) => ({
|
|
id: entry.id,
|
|
workspaceId: entry.workspaceId,
|
|
slug: entry.slug,
|
|
title: entry.title,
|
|
content: entry.content,
|
|
contentHtml: entry.contentHtml,
|
|
summary: entry.summary,
|
|
status: entry.status,
|
|
visibility: entry.visibility,
|
|
createdAt: entry.createdAt,
|
|
updatedAt: entry.updatedAt,
|
|
createdBy: entry.createdBy,
|
|
updatedBy: entry.updatedBy,
|
|
tags: entry.tags.map((et) => ({
|
|
id: et.tag.id,
|
|
name: et.tag.name,
|
|
slug: et.tag.slug,
|
|
color: et.tag.color,
|
|
})),
|
|
}));
|
|
|
|
return {
|
|
data,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a single entry by slug
|
|
*/
|
|
async findOne(workspaceId: string, slug: string): Promise<KnowledgeEntryWithTags> {
|
|
// Check cache first
|
|
const cached = await this.cache.getEntry<KnowledgeEntryWithTags>(workspaceId, slug);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
// Cache miss - fetch from database
|
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
const result: KnowledgeEntryWithTags = {
|
|
id: entry.id,
|
|
workspaceId: entry.workspaceId,
|
|
slug: entry.slug,
|
|
title: entry.title,
|
|
content: entry.content,
|
|
contentHtml: entry.contentHtml,
|
|
summary: entry.summary,
|
|
status: entry.status,
|
|
visibility: entry.visibility,
|
|
createdAt: entry.createdAt,
|
|
updatedAt: entry.updatedAt,
|
|
createdBy: entry.createdBy,
|
|
updatedBy: entry.updatedBy,
|
|
tags: entry.tags.map((et) => ({
|
|
id: et.tag.id,
|
|
name: et.tag.name,
|
|
slug: et.tag.slug,
|
|
color: et.tag.color,
|
|
})),
|
|
};
|
|
|
|
// Populate cache
|
|
await this.cache.setEntry(workspaceId, slug, result);
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create a new entry
|
|
*/
|
|
async create(
|
|
workspaceId: string,
|
|
userId: string,
|
|
createDto: CreateEntryDto
|
|
): Promise<KnowledgeEntryWithTags> {
|
|
// Generate slug from title
|
|
const baseSlug = this.generateSlug(createDto.title);
|
|
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
|
|
|
// Render markdown to HTML with sanitization
|
|
const contentHtml = await renderMarkdown(createDto.content);
|
|
|
|
// Use transaction to ensure atomicity
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
// Create entry
|
|
const entry = await tx.knowledgeEntry.create({
|
|
data: {
|
|
workspaceId,
|
|
slug,
|
|
title: createDto.title,
|
|
content: createDto.content,
|
|
contentHtml,
|
|
summary: createDto.summary ?? null,
|
|
status: createDto.status ?? EntryStatus.DRAFT,
|
|
visibility: createDto.visibility ?? "PRIVATE",
|
|
createdBy: userId,
|
|
updatedBy: userId,
|
|
},
|
|
});
|
|
|
|
// Create initial version
|
|
await tx.knowledgeEntryVersion.create({
|
|
data: {
|
|
entryId: entry.id,
|
|
version: 1,
|
|
title: entry.title,
|
|
content: entry.content,
|
|
summary: entry.summary,
|
|
createdBy: userId,
|
|
changeNote: createDto.changeNote ?? "Initial version",
|
|
},
|
|
});
|
|
|
|
// Handle tags if provided
|
|
if (createDto.tags && createDto.tags.length > 0) {
|
|
await this.syncTags(tx, workspaceId, entry.id, createDto.tags);
|
|
}
|
|
|
|
// Fetch with tags
|
|
return tx.knowledgeEntry.findUnique({
|
|
where: { id: entry.id },
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!result) {
|
|
throw new Error("Failed to create entry");
|
|
}
|
|
|
|
// Sync wiki links after entry creation
|
|
await this.linkSync.syncLinks(workspaceId, result.id, createDto.content);
|
|
|
|
// Generate and store embedding asynchronously (don't block the response)
|
|
this.generateEntryEmbedding(result.id, result.title, result.content).catch((error: unknown) => {
|
|
this.logger.warn(`Failed to generate embedding for entry - embedding will be missing`, {
|
|
entryId: result.id,
|
|
workspaceId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
});
|
|
|
|
// Invalidate search and graph caches (new entry affects search results)
|
|
await this.cache.invalidateSearches(workspaceId);
|
|
await this.cache.invalidateGraphs(workspaceId);
|
|
|
|
return {
|
|
id: result.id,
|
|
workspaceId: result.workspaceId,
|
|
slug: result.slug,
|
|
title: result.title,
|
|
content: result.content,
|
|
contentHtml: result.contentHtml,
|
|
summary: result.summary,
|
|
status: result.status,
|
|
visibility: result.visibility,
|
|
createdAt: result.createdAt,
|
|
updatedAt: result.updatedAt,
|
|
createdBy: result.createdBy,
|
|
updatedBy: result.updatedBy,
|
|
tags: result.tags.map((et) => ({
|
|
id: et.tag.id,
|
|
name: et.tag.name,
|
|
slug: et.tag.slug,
|
|
color: et.tag.color,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update an entry
|
|
*/
|
|
async update(
|
|
workspaceId: string,
|
|
slug: string,
|
|
userId: string,
|
|
updateDto: UpdateEntryDto
|
|
): Promise<KnowledgeEntryWithTags> {
|
|
// Find existing entry
|
|
const existing = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
include: {
|
|
versions: {
|
|
orderBy: {
|
|
version: "desc",
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
// If title is being updated, generate new slug if needed
|
|
let newSlug = slug;
|
|
if (updateDto.title && updateDto.title !== existing.title) {
|
|
const baseSlug = this.generateSlug(updateDto.title);
|
|
if (baseSlug !== slug) {
|
|
newSlug = await this.ensureUniqueSlug(workspaceId, baseSlug, slug);
|
|
}
|
|
}
|
|
|
|
// Render markdown if content is updated
|
|
let contentHtml = existing.contentHtml;
|
|
if (updateDto.content) {
|
|
contentHtml = await renderMarkdown(updateDto.content);
|
|
}
|
|
|
|
// Build update data object conditionally
|
|
const updateData: Prisma.KnowledgeEntryUpdateInput = {
|
|
updatedBy: userId,
|
|
};
|
|
|
|
if (newSlug !== slug) {
|
|
updateData.slug = newSlug;
|
|
}
|
|
if (updateDto.title !== undefined) {
|
|
updateData.title = updateDto.title;
|
|
}
|
|
if (updateDto.content !== undefined) {
|
|
updateData.content = updateDto.content;
|
|
updateData.contentHtml = contentHtml;
|
|
}
|
|
if (updateDto.summary !== undefined) {
|
|
updateData.summary = updateDto.summary ?? null;
|
|
}
|
|
if (updateDto.status !== undefined) {
|
|
updateData.status = updateDto.status;
|
|
}
|
|
if (updateDto.visibility !== undefined) {
|
|
updateData.visibility = updateDto.visibility;
|
|
}
|
|
|
|
// Use transaction to ensure atomicity
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
// Update entry
|
|
const entry = await tx.knowledgeEntry.update({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
data: updateData,
|
|
});
|
|
|
|
// Create new version if content or title changed
|
|
if (updateDto.title || updateDto.content) {
|
|
const latestVersion = existing.versions[0];
|
|
const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
|
|
|
|
await tx.knowledgeEntryVersion.create({
|
|
data: {
|
|
entryId: entry.id,
|
|
version: nextVersion,
|
|
title: entry.title,
|
|
content: entry.content,
|
|
summary: entry.summary,
|
|
createdBy: userId,
|
|
changeNote: updateDto.changeNote ?? `Update version ${nextVersion.toString()}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Handle tags if provided
|
|
if (updateDto.tags !== undefined) {
|
|
await this.syncTags(tx, workspaceId, entry.id, updateDto.tags);
|
|
}
|
|
|
|
// Fetch with tags
|
|
return tx.knowledgeEntry.findUnique({
|
|
where: { id: entry.id },
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!result) {
|
|
throw new Error("Failed to update entry");
|
|
}
|
|
|
|
// Sync wiki links after entry update (only if content changed)
|
|
if (updateDto.content !== undefined) {
|
|
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
|
|
}
|
|
|
|
// Regenerate embedding if content or title changed (async, don't block response)
|
|
if (updateDto.content !== undefined || updateDto.title !== undefined) {
|
|
this.generateEntryEmbedding(result.id, result.title, result.content).catch(
|
|
(error: unknown) => {
|
|
this.logger.warn(`Failed to generate embedding for entry - embedding will be missing`, {
|
|
entryId: result.id,
|
|
workspaceId,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
// Invalidate caches
|
|
// Invalidate old slug cache if slug changed
|
|
if (newSlug !== slug) {
|
|
await this.cache.invalidateEntry(workspaceId, slug);
|
|
}
|
|
// Invalidate new slug cache
|
|
await this.cache.invalidateEntry(workspaceId, result.slug);
|
|
// Invalidate search caches (content/title/tags may have changed)
|
|
await this.cache.invalidateSearches(workspaceId);
|
|
// Invalidate graph caches if links changed
|
|
if (updateDto.content !== undefined) {
|
|
await this.cache.invalidateGraphsForEntry(workspaceId, result.id);
|
|
}
|
|
|
|
return {
|
|
id: result.id,
|
|
workspaceId: result.workspaceId,
|
|
slug: result.slug,
|
|
title: result.title,
|
|
content: result.content,
|
|
contentHtml: result.contentHtml,
|
|
summary: result.summary,
|
|
status: result.status,
|
|
visibility: result.visibility,
|
|
createdAt: result.createdAt,
|
|
updatedAt: result.updatedAt,
|
|
createdBy: result.createdBy,
|
|
updatedBy: result.updatedBy,
|
|
tags: result.tags.map((et) => ({
|
|
id: et.tag.id,
|
|
name: et.tag.name,
|
|
slug: et.tag.slug,
|
|
color: et.tag.color,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete an entry (soft delete by setting status to ARCHIVED)
|
|
*/
|
|
async remove(workspaceId: string, slug: string, userId: string): Promise<void> {
|
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
await this.prisma.knowledgeEntry.update({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
data: {
|
|
status: EntryStatus.ARCHIVED,
|
|
updatedBy: userId,
|
|
},
|
|
});
|
|
|
|
// Invalidate caches
|
|
await this.cache.invalidateEntry(workspaceId, slug);
|
|
await this.cache.invalidateSearches(workspaceId);
|
|
await this.cache.invalidateGraphsForEntry(workspaceId, entry.id);
|
|
}
|
|
|
|
/**
|
|
* Generate a URL-friendly slug from a title
|
|
*/
|
|
private generateSlug(title: string): string {
|
|
return slugify(title, {
|
|
lower: true,
|
|
strict: true,
|
|
trim: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Ensure slug is unique by appending a number if needed
|
|
*/
|
|
private async ensureUniqueSlug(
|
|
workspaceId: string,
|
|
baseSlug: string,
|
|
currentSlug?: string
|
|
): Promise<string> {
|
|
let slug = baseSlug;
|
|
let counter = 1;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
while (true) {
|
|
// Check if slug exists (excluding current entry if updating)
|
|
const existing = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Slug is available
|
|
if (!existing) {
|
|
return slug;
|
|
}
|
|
|
|
// If this is the current entry being updated, keep the slug
|
|
if (currentSlug && existing.slug === currentSlug) {
|
|
return slug;
|
|
}
|
|
|
|
// Try next variation
|
|
slug = `${baseSlug}-${counter.toString()}`;
|
|
counter++;
|
|
|
|
// Safety limit to prevent infinite loops
|
|
if (counter > 1000) {
|
|
throw new ConflictException("Unable to generate unique slug after 1000 attempts");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all versions for an entry (paginated)
|
|
*/
|
|
async findVersions(
|
|
workspaceId: string,
|
|
slug: string,
|
|
page = 1,
|
|
limit = 20
|
|
): Promise<PaginatedVersions> {
|
|
// Find the entry to get its ID
|
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
// Get total count
|
|
const total = await this.prisma.knowledgeEntryVersion.count({
|
|
where: { entryId: entry.id },
|
|
});
|
|
|
|
// Get versions with author information
|
|
const versions = await this.prisma.knowledgeEntryVersion.findMany({
|
|
where: { entryId: entry.id },
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
version: "desc",
|
|
},
|
|
skip,
|
|
take: limit,
|
|
});
|
|
|
|
// Transform to response format
|
|
const data: KnowledgeEntryVersionWithAuthor[] = versions.map((v) => ({
|
|
id: v.id,
|
|
entryId: v.entryId,
|
|
version: v.version,
|
|
title: v.title,
|
|
content: v.content,
|
|
summary: v.summary,
|
|
createdAt: v.createdAt,
|
|
createdBy: v.createdBy,
|
|
changeNote: v.changeNote,
|
|
author: v.author,
|
|
}));
|
|
|
|
return {
|
|
data,
|
|
pagination: {
|
|
page,
|
|
limit,
|
|
total,
|
|
totalPages: Math.ceil(total / limit),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a specific version of an entry
|
|
*/
|
|
async findVersion(
|
|
workspaceId: string,
|
|
slug: string,
|
|
version: number
|
|
): Promise<KnowledgeEntryVersionWithAuthor> {
|
|
// Find the entry to get its ID
|
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
// Get the specific version
|
|
const versionData = await this.prisma.knowledgeEntryVersion.findUnique({
|
|
where: {
|
|
entryId_version: {
|
|
entryId: entry.id,
|
|
version,
|
|
},
|
|
},
|
|
include: {
|
|
author: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!versionData) {
|
|
throw new NotFoundException(`Version ${version.toString()} not found for entry "${slug}"`);
|
|
}
|
|
|
|
return {
|
|
id: versionData.id,
|
|
entryId: versionData.entryId,
|
|
version: versionData.version,
|
|
title: versionData.title,
|
|
content: versionData.content,
|
|
summary: versionData.summary,
|
|
createdAt: versionData.createdAt,
|
|
createdBy: versionData.createdBy,
|
|
changeNote: versionData.changeNote,
|
|
author: versionData.author,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore a previous version of an entry
|
|
*/
|
|
async restoreVersion(
|
|
workspaceId: string,
|
|
slug: string,
|
|
version: number,
|
|
userId: string,
|
|
changeNote?: string
|
|
): Promise<KnowledgeEntryWithTags> {
|
|
// Get the version to restore
|
|
const versionToRestore = await this.findVersion(workspaceId, slug, version);
|
|
|
|
// Find the current entry
|
|
const entry = await this.prisma.knowledgeEntry.findUnique({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
include: {
|
|
versions: {
|
|
orderBy: {
|
|
version: "desc",
|
|
},
|
|
take: 1,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!entry) {
|
|
throw new NotFoundException(`Knowledge entry with slug "${slug}" not found`);
|
|
}
|
|
|
|
// Render markdown for the restored content
|
|
const contentHtml = await renderMarkdown(versionToRestore.content);
|
|
|
|
// Use transaction to ensure atomicity
|
|
const result = await this.prisma.$transaction(async (tx) => {
|
|
// Update entry with restored content
|
|
const updated = await tx.knowledgeEntry.update({
|
|
where: {
|
|
workspaceId_slug: {
|
|
workspaceId,
|
|
slug,
|
|
},
|
|
},
|
|
data: {
|
|
title: versionToRestore.title,
|
|
content: versionToRestore.content,
|
|
contentHtml,
|
|
summary: versionToRestore.summary,
|
|
updatedBy: userId,
|
|
},
|
|
});
|
|
|
|
// Create new version for the restore operation
|
|
const latestVersion = entry.versions[0];
|
|
const nextVersion = latestVersion ? latestVersion.version + 1 : 1;
|
|
|
|
await tx.knowledgeEntryVersion.create({
|
|
data: {
|
|
entryId: updated.id,
|
|
version: nextVersion,
|
|
title: updated.title,
|
|
content: updated.content,
|
|
summary: updated.summary,
|
|
createdBy: userId,
|
|
changeNote: changeNote ?? `Restored from version ${version.toString()}`,
|
|
},
|
|
});
|
|
|
|
// Fetch with tags
|
|
return tx.knowledgeEntry.findUnique({
|
|
where: { id: updated.id },
|
|
include: {
|
|
tags: {
|
|
include: {
|
|
tag: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!result) {
|
|
throw new Error("Failed to restore version");
|
|
}
|
|
|
|
// Sync wiki links after restore
|
|
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
|
|
|
|
// Invalidate caches (content changed, links may have changed)
|
|
await this.cache.invalidateEntry(workspaceId, slug);
|
|
await this.cache.invalidateSearches(workspaceId);
|
|
await this.cache.invalidateGraphsForEntry(workspaceId, result.id);
|
|
|
|
return {
|
|
id: result.id,
|
|
workspaceId: result.workspaceId,
|
|
slug: result.slug,
|
|
title: result.title,
|
|
content: result.content,
|
|
contentHtml: result.contentHtml,
|
|
summary: result.summary,
|
|
status: result.status,
|
|
visibility: result.visibility,
|
|
createdAt: result.createdAt,
|
|
updatedAt: result.updatedAt,
|
|
createdBy: result.createdBy,
|
|
updatedBy: result.updatedBy,
|
|
tags: result.tags.map((et) => ({
|
|
id: et.tag.id,
|
|
name: et.tag.name,
|
|
slug: et.tag.slug,
|
|
color: et.tag.color,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sync tags for an entry (create missing tags, update associations)
|
|
*/
|
|
private async syncTags(
|
|
tx: Prisma.TransactionClient,
|
|
workspaceId: string,
|
|
entryId: string,
|
|
tagNames: string[]
|
|
): Promise<void> {
|
|
// Remove all existing tag associations
|
|
await tx.knowledgeEntryTag.deleteMany({
|
|
where: { entryId },
|
|
});
|
|
|
|
// If no tags provided, we're done
|
|
if (tagNames.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Build slug map: slug -> original tag name
|
|
const slugToName = new Map<string, string>();
|
|
for (const name of tagNames) {
|
|
slugToName.set(this.generateSlug(name), name);
|
|
}
|
|
const tagSlugs = [...slugToName.keys()];
|
|
|
|
// Batch fetch all existing tags in a single query (fixes N+1)
|
|
const existingTags = await tx.knowledgeTag.findMany({
|
|
where: {
|
|
workspaceId,
|
|
slug: { in: tagSlugs },
|
|
},
|
|
});
|
|
|
|
// Determine which tags need to be created
|
|
const existingSlugs = new Set(existingTags.map((t) => t.slug));
|
|
const missingSlugs = tagSlugs.filter((s) => !existingSlugs.has(s));
|
|
|
|
// Create missing tags
|
|
const newTags = await Promise.all(
|
|
missingSlugs.map((slug) => {
|
|
const name = slugToName.get(slug) ?? slug;
|
|
return tx.knowledgeTag.create({
|
|
data: {
|
|
workspaceId,
|
|
name,
|
|
slug,
|
|
},
|
|
});
|
|
})
|
|
);
|
|
|
|
const allTags = [...existingTags, ...newTags];
|
|
|
|
// Create tag associations in a single batch
|
|
await tx.knowledgeEntryTag.createMany({
|
|
data: allTags.map((tag) => ({
|
|
entryId,
|
|
tagId: tag.id,
|
|
})),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Generate and store embedding for a knowledge entry
|
|
* Private helper method called asynchronously after entry create/update
|
|
* Queues the embedding generation job instead of processing synchronously
|
|
*/
|
|
private async generateEntryEmbedding(
|
|
entryId: string,
|
|
title: string,
|
|
content: string
|
|
): Promise<void> {
|
|
const combinedContent = this.ollamaEmbedding.prepareContentForEmbedding(title, content);
|
|
|
|
try {
|
|
const jobId = await this.embeddingQueue.queueEmbeddingJob(entryId, combinedContent);
|
|
this.logger.log(`Queued embedding job ${jobId} for entry ${entryId}`);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to queue embedding job for entry ${entryId}`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Batch generate embeddings for all entries in a workspace
|
|
* Useful for populating embeddings for existing entries
|
|
*
|
|
* @param workspaceId - The workspace ID
|
|
* @param status - Optional status filter (default: not ARCHIVED)
|
|
* @returns Number of embeddings successfully generated
|
|
*/
|
|
async batchGenerateEmbeddings(
|
|
workspaceId: string,
|
|
status?: EntryStatus
|
|
): Promise<{ total: number; success: number }> {
|
|
const where: Prisma.KnowledgeEntryWhereInput = {
|
|
workspaceId,
|
|
status: status ?? { not: EntryStatus.ARCHIVED },
|
|
};
|
|
|
|
const entries = await this.prisma.knowledgeEntry.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
content: true,
|
|
},
|
|
});
|
|
|
|
const entriesForEmbedding = entries.map((entry) => ({
|
|
id: entry.id,
|
|
content: this.embedding.prepareContentForEmbedding(entry.title, entry.content),
|
|
}));
|
|
|
|
const successCount = await this.embedding.batchGenerateEmbeddings(entriesForEmbedding);
|
|
|
|
return {
|
|
total: entries.length,
|
|
success: successCount,
|
|
};
|
|
}
|
|
}
|