feat(knowledge): add tag management API (KNOW-003)
- Add Tag DTOs (CreateTagDto, UpdateTagDto) with validation - Implement TagsService with CRUD operations - Add TagsController with authenticated endpoints - Support automatic slug generation from tag names - Add workspace isolation for tags - Include entry count in tag responses - Add findOrCreateTags method for entry creation/update - Implement comprehensive test coverage (29 tests passing) Endpoints: - GET /api/knowledge/tags - List workspace tags - POST /api/knowledge/tags - Create tag - GET /api/knowledge/tags/:slug - Get tag by slug - PUT /api/knowledge/tags/:slug - Update tag - DELETE /api/knowledge/tags/:slug - Delete tag - GET /api/knowledge/tags/:slug/entries - List entries with tag Related: KNOW-003
This commit is contained in:
540
apps/api/src/knowledge/knowledge.service.ts
Normal file
540
apps/api/src/knowledge/knowledge.service.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { marked } from "marked";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Service for managing knowledge entries
|
||||
*/
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
// Configure marked for security and consistency
|
||||
marked.setOptions({
|
||||
gfm: true, // GitHub Flavored Markdown
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: 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<KnowledgeEntryWithTags> {
|
||||
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<KnowledgeEntryWithTags> {
|
||||
// Generate slug from title
|
||||
const baseSlug = this.generateSlug(createDto.title);
|
||||
const slug = await this.ensureUniqueSlug(workspaceId, baseSlug);
|
||||
|
||||
// Render markdown to HTML
|
||||
const contentHtml = await marked.parse(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,
|
||||
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<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 marked.parse(updateDto.content);
|
||||
}
|
||||
|
||||
// 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: {
|
||||
slug: newSlug,
|
||||
title: updateDto.title,
|
||||
content: updateDto.content,
|
||||
contentHtml,
|
||||
summary: updateDto.summary,
|
||||
status: updateDto.status,
|
||||
visibility: updateDto.visibility,
|
||||
updatedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// 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<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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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<void> {
|
||||
// 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,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user