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
This commit is contained in:
Jason Woltje
2026-01-29 23:27:03 -06:00
parent 59aec28d5c
commit 7465d0a3c2
14 changed files with 2450 additions and 24 deletions

View File

@@ -11,6 +11,10 @@ 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";
@@ -498,6 +502,264 @@ export class KnowledgeService {
}
}
/**
* 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)
*/