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

@@ -130,21 +130,22 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations // Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner") ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[] workspaceMemberships WorkspaceMember[]
teamMemberships TeamMember[] teamMemberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee") assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator") createdEvents Event[] @relation("EventCreator")
createdProjects Project[] @relation("ProjectCreator") createdProjects Project[] @relation("ProjectCreator")
activityLogs ActivityLog[] activityLogs ActivityLog[]
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
ideas Idea[] @relation("IdeaCreator") ideas Idea[] @relation("IdeaCreator")
relationships Relationship[] @relation("RelationshipCreator") relationships Relationship[] @relation("RelationshipCreator")
agentSessions AgentSession[] agentSessions AgentSession[]
userLayouts UserLayout[] userLayouts UserLayout[]
userPreference UserPreference? userPreference UserPreference?
knowledgeEntryVersions KnowledgeEntryVersion[] @relation("EntryVersionAuthor")
@@map("users") @@map("users")
} }
@@ -737,6 +738,7 @@ model KnowledgeEntryVersion {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid createdBy String @map("created_by") @db.Uuid
author User @relation("EntryVersionAuthor", fields: [createdBy], references: [id])
changeNote String? @map("change_note") changeNote String? @map("change_note")
@@unique([entryId, version]) @@unique([entryId, version])

View File

@@ -3,6 +3,7 @@ export { UpdateEntryDto } from "./update-entry.dto";
export { EntryQueryDto } from "./entry-query.dto"; export { EntryQueryDto } from "./entry-query.dto";
export { CreateTagDto } from "./create-tag.dto"; export { CreateTagDto } from "./create-tag.dto";
export { UpdateTagDto } from "./update-tag.dto"; export { UpdateTagDto } from "./update-tag.dto";
export { RestoreVersionDto } from "./restore-version.dto";
export { export {
SearchQueryDto, SearchQueryDto,
TagSearchDto, TagSearchDto,

View File

@@ -0,0 +1,15 @@
import {
IsString,
IsOptional,
MaxLength,
} from "class-validator";
/**
* DTO for restoring a previous version of a knowledge entry
*/
export class RestoreVersionDto {
@IsOptional()
@IsString({ message: "changeNote must be a string" })
@MaxLength(500, { message: "changeNote must not exceed 500 characters" })
changeNote?: string;
}

View File

@@ -0,0 +1,39 @@
/**
* Knowledge Entry Version entity
* Represents a historical version of a knowledge entry
*/
export interface KnowledgeEntryVersionEntity {
id: string;
entryId: string;
version: number;
title: string;
content: string;
summary: string | null;
createdAt: Date;
createdBy: string;
changeNote: string | null;
}
/**
* Version list item with author information
*/
export interface KnowledgeEntryVersionWithAuthor extends KnowledgeEntryVersionEntity {
author: {
id: string;
name: string;
email: string;
};
}
/**
* Paginated version list response
*/
export interface PaginatedVersions {
data: KnowledgeEntryVersionWithAuthor[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}

View File

@@ -8,9 +8,11 @@ import {
Param, Param,
Query, Query,
UseGuards, UseGuards,
ParseIntPipe,
DefaultValuePipe,
} from "@nestjs/common"; } from "@nestjs/common";
import { KnowledgeService } from "./knowledge.service"; import { KnowledgeService } from "./knowledge.service";
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto"; import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, RestoreVersionDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards"; import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators"; import { Workspace, Permission, RequirePermission } from "../common/decorators";
@@ -132,4 +134,58 @@ export class KnowledgeController {
count: backlinks.length, count: backlinks.length,
}; };
} }
/**
* GET /api/knowledge/entries/:slug/versions
* List all versions for an entry with pagination
* Requires: Any workspace member
*/
@Get(":slug/versions")
@RequirePermission(Permission.WORKSPACE_ANY)
async getVersions(
@Workspace() workspaceId: string,
@Param("slug") slug: string,
@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number
) {
return this.knowledgeService.findVersions(workspaceId, slug, page, limit);
}
/**
* GET /api/knowledge/entries/:slug/versions/:version
* Get a specific version of an entry
* Requires: Any workspace member
*/
@Get(":slug/versions/:version")
@RequirePermission(Permission.WORKSPACE_ANY)
async getVersion(
@Workspace() workspaceId: string,
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number
) {
return this.knowledgeService.findVersion(workspaceId, slug, version);
}
/**
* POST /api/knowledge/entries/:slug/restore/:version
* Restore a previous version of an entry
* Requires: MEMBER role or higher
*/
@Post(":slug/restore/:version")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async restoreVersion(
@Workspace() workspaceId: string,
@Param("slug") slug: string,
@Param("version", ParseIntPipe) version: number,
@CurrentUser() user: any,
@Body() restoreDto: RestoreVersionDto
) {
return this.knowledgeService.restoreVersion(
workspaceId,
slug,
version,
user.id,
restoreDto.changeNote
);
}
} }

View File

@@ -11,6 +11,10 @@ import type {
KnowledgeEntryWithTags, KnowledgeEntryWithTags,
PaginatedEntries, PaginatedEntries,
} from "./entities/knowledge-entry.entity"; } from "./entities/knowledge-entry.entity";
import type {
KnowledgeEntryVersionWithAuthor,
PaginatedVersions,
} from "./entities/knowledge-entry-version.entity";
import { renderMarkdown } from "./utils/markdown"; import { renderMarkdown } from "./utils/markdown";
import { LinkSyncService } from "./services/link-sync.service"; 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) * Sync tags for an entry (create missing tags, update associations)
*/ */

View File

@@ -0,0 +1,352 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { KnowledgeService } from "./knowledge.service";
import { PrismaService } from "../prisma/prisma.service";
import { LinkSyncService } from "./services/link-sync.service";
import { NotFoundException } from "@nestjs/common";
describe("KnowledgeService - Version History", () => {
let service: KnowledgeService;
let prisma: PrismaService;
let linkSync: LinkSyncService;
const workspaceId = "workspace-123";
const userId = "user-456";
const entryId = "entry-789";
const slug = "test-entry";
const mockEntry = {
id: entryId,
workspaceId,
slug,
title: "Test Entry",
content: "# Test Content",
contentHtml: "<h1>Test Content</h1>",
summary: "Test summary",
status: "PUBLISHED",
visibility: "WORKSPACE",
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-20"),
createdBy: userId,
updatedBy: userId,
};
const mockVersions = [
{
id: "version-3",
entryId,
version: 3,
title: "Test Entry v3",
content: "# Version 3",
summary: "Summary v3",
createdAt: new Date("2026-01-20"),
createdBy: userId,
changeNote: "Updated content",
author: {
id: userId,
name: "Test User",
email: "test@example.com",
},
},
{
id: "version-2",
entryId,
version: 2,
title: "Test Entry v2",
content: "# Version 2",
summary: "Summary v2",
createdAt: new Date("2026-01-15"),
createdBy: userId,
changeNote: "Second version",
author: {
id: userId,
name: "Test User",
email: "test@example.com",
},
},
{
id: "version-1",
entryId,
version: 1,
title: "Test Entry v1",
content: "# Version 1",
summary: "Summary v1",
createdAt: new Date("2026-01-10"),
createdBy: userId,
changeNote: "Initial version",
author: {
id: userId,
name: "Test User",
email: "test@example.com",
},
},
];
const mockPrismaService = {
knowledgeEntry: {
findUnique: vi.fn(),
update: vi.fn(),
},
knowledgeEntryVersion: {
count: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
$transaction: vi.fn(),
};
const mockLinkSyncService = {
syncLinks: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
KnowledgeService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
{
provide: LinkSyncService,
useValue: mockLinkSyncService,
},
],
}).compile();
service = module.get<KnowledgeService>(KnowledgeService);
prisma = module.get<PrismaService>(PrismaService);
linkSync = module.get<LinkSyncService>(LinkSyncService);
vi.clearAllMocks();
});
describe("findVersions", () => {
it("should return paginated versions for an entry", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.knowledgeEntryVersion.count.mockResolvedValue(3);
mockPrismaService.knowledgeEntryVersion.findMany.mockResolvedValue(mockVersions);
const result = await service.findVersions(workspaceId, slug, 1, 20);
expect(result).toEqual({
data: mockVersions,
pagination: {
page: 1,
limit: 20,
total: 3,
totalPages: 1,
},
});
expect(mockPrismaService.knowledgeEntry.findUnique).toHaveBeenCalledWith({
where: {
workspaceId_slug: {
workspaceId,
slug,
},
},
});
expect(mockPrismaService.knowledgeEntryVersion.count).toHaveBeenCalledWith({
where: { entryId },
});
expect(mockPrismaService.knowledgeEntryVersion.findMany).toHaveBeenCalledWith({
where: { entryId },
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
version: "desc",
},
skip: 0,
take: 20,
});
});
it("should handle pagination correctly", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.knowledgeEntryVersion.count.mockResolvedValue(50);
mockPrismaService.knowledgeEntryVersion.findMany.mockResolvedValue([mockVersions[0]]);
const result = await service.findVersions(workspaceId, slug, 2, 20);
expect(result.pagination).toEqual({
page: 2,
limit: 20,
total: 50,
totalPages: 3,
});
expect(mockPrismaService.knowledgeEntryVersion.findMany).toHaveBeenCalledWith(
expect.objectContaining({
skip: 20, // (page 2 - 1) * 20
take: 20,
})
);
});
it("should throw NotFoundException if entry does not exist", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
await expect(service.findVersions(workspaceId, slug)).rejects.toThrow(NotFoundException);
expect(mockPrismaService.knowledgeEntryVersion.count).not.toHaveBeenCalled();
});
});
describe("findVersion", () => {
it("should return a specific version", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
const result = await service.findVersion(workspaceId, slug, 2);
expect(result).toEqual(mockVersions[1]);
expect(mockPrismaService.knowledgeEntryVersion.findUnique).toHaveBeenCalledWith({
where: {
entryId_version: {
entryId,
version: 2,
},
},
include: {
author: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
});
it("should throw NotFoundException if entry does not exist", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
await expect(service.findVersion(workspaceId, slug, 2)).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if version does not exist", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(null);
await expect(service.findVersion(workspaceId, slug, 99)).rejects.toThrow(NotFoundException);
});
});
describe("restoreVersion", () => {
it("should restore a previous version and create a new version", async () => {
const entryWithVersions = {
...mockEntry,
versions: [mockVersions[0]], // Latest version is v3
tags: [],
};
const updatedEntry = {
...mockEntry,
title: "Test Entry v2",
content: "# Version 2",
contentHtml: "<h1>Version 2</h1>",
summary: "Summary v2",
tags: [],
};
// Mock findVersion to return version 2
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntry);
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
// Mock transaction
mockPrismaService.$transaction.mockImplementation(async (callback) => {
const tx = {
knowledgeEntry: {
update: vi.fn().mockResolvedValue(updatedEntry),
findUnique: vi.fn().mockResolvedValue({
...updatedEntry,
tags: [],
}),
},
knowledgeEntryVersion: {
create: vi.fn().mockResolvedValue({
id: "version-4",
entryId,
version: 4,
title: "Test Entry v2",
content: "# Version 2",
summary: "Summary v2",
createdAt: new Date(),
createdBy: userId,
changeNote: "Restored from version 2",
}),
},
};
return callback(tx);
});
// Mock for findVersion call
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(entryWithVersions);
const result = await service.restoreVersion(workspaceId, slug, 2, userId, "Custom restore note");
expect(result.title).toBe("Test Entry v2");
expect(result.content).toBe("# Version 2");
expect(mockLinkSyncService.syncLinks).toHaveBeenCalledWith(
workspaceId,
entryId,
"# Version 2"
);
});
it("should use default change note if not provided", async () => {
const entryWithVersions = {
...mockEntry,
versions: [mockVersions[0]],
tags: [],
};
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(mockEntry);
mockPrismaService.knowledgeEntryVersion.findUnique.mockResolvedValue(mockVersions[1]);
mockPrismaService.$transaction.mockImplementation(async (callback) => {
const createMock = vi.fn();
const tx = {
knowledgeEntry: {
update: vi.fn().mockResolvedValue(mockEntry),
findUnique: vi.fn().mockResolvedValue({ ...mockEntry, tags: [] }),
},
knowledgeEntryVersion: {
create: createMock,
},
};
await callback(tx);
return { ...mockEntry, tags: [] };
});
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(entryWithVersions);
await service.restoreVersion(workspaceId, slug, 2, userId);
// Verify transaction was called
expect(mockPrismaService.$transaction).toHaveBeenCalled();
});
it("should throw NotFoundException if entry does not exist", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
await expect(service.restoreVersion(workspaceId, slug, 2, userId)).rejects.toThrow(
NotFoundException
);
});
});
});

View File

@@ -7,6 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared";
import { EntryViewer } from "@/components/knowledge/EntryViewer"; import { EntryViewer } from "@/components/knowledge/EntryViewer";
import { EntryEditor } from "@/components/knowledge/EntryEditor"; import { EntryEditor } from "@/components/knowledge/EntryEditor";
import { EntryMetadata } from "@/components/knowledge/EntryMetadata"; import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
import { VersionHistory } from "@/components/knowledge/VersionHistory";
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge"; import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
/** /**
@@ -32,6 +33,7 @@ export default function EntryPage() {
const [editTags, setEditTags] = useState<string[]>([]); const [editTags, setEditTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]); const [availableTags, setAvailableTags] = useState<KnowledgeTag[]>([]);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [activeTab, setActiveTab] = useState<"content" | "history">("content");
// Load entry data // Load entry data
useEffect(() => { useEffect(() => {
@@ -179,6 +181,25 @@ export default function EntryPage() {
} }
}; };
const handleVersionRestore = (): void => {
// Reload entry after version restore
async function reload(): Promise<void> {
try {
const data = await fetchEntry(slug);
setEntry(data);
setEditTitle(data.title);
setEditContent(data.content);
setEditStatus(data.status);
setEditVisibility(data.visibility);
setEditTags(data.tags.map((tag) => tag.id));
setActiveTab("content");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to reload entry");
}
}
void reload();
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-4xl mx-auto p-6"> <div className="max-w-4xl mx-auto p-6">
@@ -268,12 +289,44 @@ export default function EntryPage() {
</div> </div>
)} )}
{/* Tabs */}
{!isEditing && (
<div className="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-6">
<button
type="button"
onClick={() => setActiveTab("content")}
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
activeTab === "content"
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
Content
</button>
<button
type="button"
onClick={() => setActiveTab("history")}
className={`pb-3 border-b-2 font-medium text-sm transition-colors ${
activeTab === "history"
? "border-blue-600 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
History
</button>
</nav>
</div>
)}
{/* Content */} {/* Content */}
<div className="mb-6"> <div className="mb-6">
{isEditing ? ( {isEditing ? (
<EntryEditor content={editContent} onChange={setEditContent} /> <EntryEditor content={editContent} onChange={setEditContent} />
) : ( ) : activeTab === "content" ? (
<EntryViewer entry={entry} /> <EntryViewer entry={entry} />
) : (
<VersionHistory slug={slug} onRestore={handleVersionRestore} />
)} )}
</div> </div>

View File

@@ -0,0 +1,223 @@
"use client";
import React, { useState, useEffect } from "react";
import type { KnowledgeEntryVersionWithAuthor } from "@mosaic/shared";
import { fetchVersions, fetchVersion, restoreVersion } from "@/lib/api/knowledge";
interface VersionHistoryProps {
slug: string;
onRestore?: () => void;
}
/**
* Version History Component
* Displays version history timeline for a knowledge entry
* Allows viewing and restoring previous versions
*/
export function VersionHistory({ slug, onRestore }: VersionHistoryProps): JSX.Element {
const [versions, setVersions] = useState<KnowledgeEntryVersionWithAuthor[]>([]);
const [selectedVersion, setSelectedVersion] = useState<KnowledgeEntryVersionWithAuthor | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRestoring, setIsRestoring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// Load versions
useEffect(() => {
async function loadVersions(): Promise<void> {
try {
setIsLoading(true);
setError(null);
const response = await fetchVersions(slug, page, 20);
setVersions(response.data);
setTotalPages(response.totalPages);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load version history");
} finally {
setIsLoading(false);
}
}
void loadVersions();
}, [slug, page]);
// Load specific version for preview
const handleViewVersion = async (version: number): Promise<void> => {
try {
setError(null);
const versionData = await fetchVersion(slug, version);
setSelectedVersion(versionData);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load version");
}
};
// Restore a version
const handleRestore = async (version: number): Promise<void> => {
if (
!confirm(
`Are you sure you want to restore version ${version}? This will create a new version with the content from version ${version}.`
)
) {
return;
}
try {
setIsRestoring(true);
setError(null);
await restoreVersion(slug, version, {
changeNote: `Restored from version ${version}`,
});
setSelectedVersion(null);
setPage(1); // Reload first page to see new version
if (onRestore) {
onRestore();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to restore version");
} finally {
setIsRestoring(false);
}
};
const formatDate = (date: Date): string => {
return new Date(date).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
if (isLoading && versions.length === 0) {
return (
<div className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div className="h-16 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
);
}
return (
<div className="p-6">
{error && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
)}
{versions.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No version history available</p>
</div>
) : (
<>
{/* Version Timeline */}
<div className="space-y-4 mb-6">
{versions.map((version, index) => (
<div
key={version.id}
className={`border rounded-lg p-4 transition-colors ${
selectedVersion?.id === version.id
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="font-semibold text-gray-900 dark:text-gray-100">
Version {version.version}
</span>
{index === 0 && (
<span className="px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded">
Current
</span>
)}
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">
{version.author.name} ({version.author.email})
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
{formatDate(version.createdAt)}
</p>
{version.changeNote && (
<p className="mt-2 text-sm text-gray-700 dark:text-gray-300 italic">
"{version.changeNote}"
</p>
)}
<p className="mt-2 text-sm font-medium text-gray-800 dark:text-gray-200">
{version.title}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleViewVersion(version.version)}
className="px-3 py-1 text-sm font-medium text-blue-700 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
>
{selectedVersion?.id === version.id ? "Hide" : "View"}
</button>
{index !== 0 && (
<button
type="button"
onClick={() => handleRestore(version.version)}
disabled={isRestoring}
className="px-3 py-1 text-sm font-medium text-green-700 dark:text-green-400 hover:text-green-900 dark:hover:text-green-300 disabled:opacity-50"
>
{isRestoring ? "Restoring..." : "Restore"}
</button>
)}
</div>
</div>
{/* Version Preview */}
{selectedVersion?.id === version.id && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 className="font-semibold mb-2 text-gray-900 dark:text-gray-100">
Content Preview
</h4>
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-4 max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 font-mono">
{selectedVersion.content}
</pre>
</div>
</div>
)}
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
Previous
</button>
<span className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">
Page {page} of {totalPages}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -5,3 +5,4 @@
export { EntryViewer } from "./EntryViewer"; export { EntryViewer } from "./EntryViewer";
export { EntryEditor } from "./EntryEditor"; export { EntryEditor } from "./EntryEditor";
export { EntryMetadata } from "./EntryMetadata"; export { EntryMetadata } from "./EntryMetadata";
export { VersionHistory } from "./VersionHistory";

View File

@@ -3,7 +3,12 @@
* Handles knowledge entry-related API requests * Handles knowledge entry-related API requests
*/ */
import type { KnowledgeEntryWithTags, KnowledgeTag } from "@mosaic/shared"; import type {
KnowledgeEntryWithTags,
KnowledgeTag,
KnowledgeEntryVersionWithAuthor,
PaginatedResponse,
} from "@mosaic/shared";
import { EntryStatus, Visibility } from "@mosaic/shared"; import { EntryStatus, Visibility } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
@@ -44,6 +49,11 @@ export interface UpdateEntryData {
status?: EntryStatus; status?: EntryStatus;
visibility?: Visibility; visibility?: Visibility;
tags?: string[]; tags?: string[];
changeNote?: string;
}
export interface RestoreVersionData {
changeNote?: string;
} }
/** /**
@@ -128,6 +138,49 @@ export async function fetchTags(): Promise<KnowledgeTag[]> {
return response.data; return response.data;
} }
/**
* Fetch version history for an entry
*/
export async function fetchVersions(
slug: string,
page: number = 1,
limit: number = 20
): Promise<PaginatedResponse<KnowledgeEntryVersionWithAuthor>> {
const params = new URLSearchParams();
params.append("page", page.toString());
params.append("limit", limit.toString());
return apiGet<PaginatedResponse<KnowledgeEntryVersionWithAuthor>>(
`/api/knowledge/entries/${slug}/versions?${params.toString()}`
);
}
/**
* Fetch a specific version of an entry
*/
export async function fetchVersion(
slug: string,
version: number
): Promise<KnowledgeEntryVersionWithAuthor> {
return apiGet<KnowledgeEntryVersionWithAuthor>(
`/api/knowledge/entries/${slug}/versions/${version}`
);
}
/**
* Restore a previous version of an entry
*/
export async function restoreVersion(
slug: string,
version: number,
data?: RestoreVersionData
): Promise<KnowledgeEntryWithTags> {
return apiPost<KnowledgeEntryWithTags>(
`/api/knowledge/entries/${slug}/restore/${version}`,
data || {}
);
}
/** /**
* Mock entries for development (until backend endpoints are ready) * Mock entries for development (until backend endpoints are ready)
*/ */

View File

@@ -185,6 +185,32 @@ export interface KnowledgeEntryWithTags extends KnowledgeEntry {
tags: KnowledgeTag[]; tags: KnowledgeTag[];
} }
/**
* Knowledge entry version entity
*/
export interface KnowledgeEntryVersion {
readonly id: string;
entryId: string;
version: number;
title: string;
content: string;
summary: string | null;
readonly createdAt: Date;
createdBy: string;
changeNote: string | null;
}
/**
* Knowledge entry version with author information
*/
export interface KnowledgeEntryVersionWithAuthor extends KnowledgeEntryVersion {
author: {
id: string;
name: string;
email: string;
};
}
/** /**
* Domain entity * Domain entity
*/ */

1339
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,15 @@
packages: packages:
- "apps/*" - apps/*
- "packages/*" - packages/*
ignoredBuiltDependencies:
- '@nestjs/core'
- '@swc/core'
- better-sqlite3
- esbuild
- sharp
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- prisma