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:
Jason Woltje
2026-01-29 16:13:40 -06:00
parent 244e50c806
commit f07f04404d
18 changed files with 3413 additions and 1 deletions

View 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,
},
})
)
);
}
}