Merge: Knowledge version history - API and UI (closes #75, #76)

This commit is contained in:
Jason Woltje
2026-01-29 23:39:49 -06:00
14 changed files with 1222 additions and 140 deletions

View File

@@ -102,19 +102,6 @@ enum AgentStatus {
TERMINATED
}
enum AgentTaskStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
enum AgentTaskPriority {
LOW
MEDIUM
HIGH
}
enum EntryStatus {
DRAFT
PUBLISHED
@@ -143,22 +130,22 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[]
teamMemberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator")
createdProjects Project[] @relation("ProjectCreator")
activityLogs ActivityLog[]
sessions Session[]
accounts Account[]
ideas Idea[] @relation("IdeaCreator")
relationships Relationship[] @relation("RelationshipCreator")
agentSessions AgentSession[]
userLayouts UserLayout[]
userPreference UserPreference?
createdAgentTasks AgentTask[] @relation("AgentTaskCreator")
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[]
teamMemberships TeamMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator")
createdProjects Project[] @relation("ProjectCreator")
activityLogs ActivityLog[]
sessions Session[]
accounts Account[]
ideas Idea[] @relation("IdeaCreator")
relationships Relationship[] @relation("RelationshipCreator")
agentSessions AgentSession[]
userLayouts UserLayout[]
userPreference UserPreference?
knowledgeEntryVersions KnowledgeEntryVersion[] @relation("EntryVersionAuthor")
@@map("users")
}
@@ -202,7 +189,6 @@ model Workspace {
knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[]
cronSchedules CronSchedule[]
agentTasks AgentTask[]
@@index([ownerId])
@@map("workspaces")
@@ -588,45 +574,6 @@ model AgentSession {
@@map("agent_sessions")
}
model AgentTask {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
// Core fields
title String
description String? @db.Text
// Status and priority
status AgentTaskStatus @default(PENDING)
priority AgentTaskPriority @default(MEDIUM)
// Agent configuration
agentType String @map("agent_type")
agentConfig Json @default("{}") @map("agent_config")
// Results
result Json?
error String? @db.Text
// Audit
createdById String @map("created_by_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
startedAt DateTime? @map("started_at") @db.Timestamptz
completedAt DateTime? @map("completed_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
createdBy User @relation("AgentTaskCreator", fields: [createdById], references: [id], onDelete: Cascade)
@@unique([id, workspaceId])
@@index([workspaceId])
@@index([workspaceId, status])
@@index([workspaceId, priority])
@@index([createdById])
@@map("agent_tasks")
}
model WidgetDefinition {
id String @id @default(uuid()) @db.Uuid
@@ -791,6 +738,7 @@ model KnowledgeEntryVersion {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
createdBy String @map("created_by") @db.Uuid
author User @relation("EntryVersionAuthor", fields: [createdBy], references: [id])
changeNote String? @map("change_note")
@@unique([entryId, version])
@@ -804,22 +752,18 @@ model KnowledgeLink {
sourceId String @map("source_id") @db.Uuid
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String? @map("target_id") @db.Uuid
target KnowledgeEntry? @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
targetId String @map("target_id") @db.Uuid
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
// Link metadata
linkText String @map("link_text")
displayText String @map("display_text")
positionStart Int @map("position_start")
positionEnd Int @map("position_end")
resolved Boolean @default(false)
context String?
linkText String @map("link_text")
context String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@unique([sourceId, targetId])
@@index([sourceId])
@@index([targetId])
@@index([sourceId, resolved])
@@map("knowledge_links")
}

View File

@@ -3,6 +3,7 @@ export { UpdateEntryDto } from "./update-entry.dto";
export { EntryQueryDto } from "./entry-query.dto";
export { CreateTagDto } from "./create-tag.dto";
export { UpdateTagDto } from "./update-tag.dto";
export { RestoreVersionDto } from "./restore-version.dto";
export {
SearchQueryDto,
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,14 +8,16 @@ import {
Param,
Query,
UseGuards,
ParseIntPipe,
DefaultValuePipe,
} from "@nestjs/common";
import { KnowledgeService } from "./knowledge.service";
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, GraphQueryDto } from "./dto";
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, RestoreVersionDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { LinkSyncService, GraphService } from "./services";
import { LinkSyncService } from "./services/link-sync.service";
/**
* Controller for knowledge entry endpoints
@@ -27,8 +29,7 @@ import { LinkSyncService, GraphService } from "./services";
export class KnowledgeController {
constructor(
private readonly knowledgeService: KnowledgeService,
private readonly linkSync: LinkSyncService,
private readonly graphService: GraphService
private readonly linkSync: LinkSyncService
) {}
/**
@@ -135,29 +136,56 @@ export class KnowledgeController {
}
/**
* GET /api/knowledge/entries/:slug/graph
* Get entry-centered graph view
* Returns the entry and connected nodes with specified depth
* GET /api/knowledge/entries/:slug/versions
* List all versions for an entry with pagination
* Requires: Any workspace member
*/
@Get(":slug/graph")
@Get(":slug/versions")
@RequirePermission(Permission.WORKSPACE_ANY)
async getEntryGraph(
async getVersions(
@Workspace() workspaceId: string,
@Param("slug") slug: string,
@Query() query: GraphQueryDto
@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query("limit", new DefaultValuePipe(20), ParseIntPipe) limit: number
) {
// Find the entry to get its ID
const entry = await this.knowledgeService.findOne(workspaceId, slug);
// Get graph
const graph = await this.graphService.getEntryGraph(
workspaceId,
entry.id,
query.depth || 1
);
return graph;
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,
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)
*/

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
);
});
});
});