import { Injectable, NotFoundException, ConflictException, } from "@nestjs/common"; import { EntryStatus } 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 { renderMarkdown } from "./utils/markdown"; /** * Service for managing knowledge entries */ @Injectable() export class KnowledgeService { constructor(private readonly prisma: PrismaService) {} /** * 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: any = { workspaceId, }; if (query.status) { where.status = query.status; } if (query.tag) { where.tags = { some: { tag: { slug: query.tag, }, }, }; } // 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: { updatedAt: "desc", }, 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 { 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` ); } return { 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, })), }; } /** * 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"); } 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: any = { 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}`, }, }); } // 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"); } 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, }, }); } /** * 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; 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}`; counter++; // Safety limit to prevent infinite loops if (counter > 1000) { throw new ConflictException( "Unable to generate unique slug after 1000 attempts" ); } } } /** * Sync tags for an entry (create missing tags, update associations) */ private async syncTags( tx: any, 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; } // Get or create tags const tags = await Promise.all( tagNames.map(async (name) => { const tagSlug = this.generateSlug(name); // Try to find existing tag let tag = await tx.knowledgeTag.findUnique({ where: { workspaceId_slug: { workspaceId, slug: tagSlug, }, }, }); // Create if doesn't exist if (!tag) { tag = await tx.knowledgeTag.create({ data: { workspaceId, name, slug: tagSlug, }, }); } return tag; }) ); // Create tag associations await Promise.all( tags.map((tag) => tx.knowledgeEntryTag.create({ data: { entryId, tagId: tag.id, }, }) ) ); } }