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 { 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 { // Check cache first const cached = await this.cache.getEntry(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 { // 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 { // 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 { 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 { 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 { // 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 { // 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 { // 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 { // 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(); 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 { 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, }; } }