- 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:
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
15
apps/api/src/knowledge/dto/restore-version.dto.ts
Normal file
15
apps/api/src/knowledge/dto/restore-version.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
352
apps/api/src/knowledge/knowledge.service.versions.spec.ts
Normal file
352
apps/api/src/knowledge/knowledge.service.versions.spec.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal file
223
apps/web/src/components/knowledge/VersionHistory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
1339
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user