- 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:
@@ -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)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user