Files
stack/apps/api/src/knowledge/knowledge.service.ts
Jason Woltje 7465d0a3c2 feat: add knowledge version history (closes #75, closes #76)
- Added EntryVersion model with author relation
- Implemented automatic versioning on entry create/update
- Added API endpoints for version history:
  - GET /api/knowledge/entries/:slug/versions - list versions
  - GET /api/knowledge/entries/:slug/versions/:version - get specific
  - POST /api/knowledge/entries/:slug/restore/:version - restore version
- Created VersionHistory.tsx component with timeline view
- Added History tab to entry detail page
- Supports version viewing and restoring
- Includes comprehensive tests for version operations
- All TypeScript types are explicit and type-safe
2026-01-29 23:27:03 -06:00

825 lines
19 KiB
TypeScript

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 type {
KnowledgeEntryVersionWithAuthor,
PaginatedVersions,
} from "./entities/knowledge-entry-version.entity";
import { renderMarkdown } from "./utils/markdown";
import { LinkSyncService } from "./services/link-sync.service";
/**
* Service for managing knowledge entries
*/
@Injectable()
export class KnowledgeService {
constructor(
private readonly prisma: PrismaService,
private readonly linkSync: LinkSyncService
) {}
/**
* 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 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);
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: 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");
}
// Sync wiki links after entry update (only if content changed)
if (updateDto.content !== undefined) {
await this.linkSync.syncLinks(workspaceId, result.id, result.content);
}
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"
);
}
}
}
/**
* Get all versions for an entry (paginated)
*/
async findVersions(
workspaceId: string,
slug: string,
page: number = 1,
limit: number = 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} 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}`,
},
});
// 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);
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: 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,
},
})
)
);
}
}