diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index efc5599..6256f4d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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") } diff --git a/apps/api/src/knowledge/dto/index.ts b/apps/api/src/knowledge/dto/index.ts index 90b0dfd..f33dd5a 100644 --- a/apps/api/src/knowledge/dto/index.ts +++ b/apps/api/src/knowledge/dto/index.ts @@ -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, diff --git a/apps/api/src/knowledge/dto/restore-version.dto.ts b/apps/api/src/knowledge/dto/restore-version.dto.ts new file mode 100644 index 0000000..10be265 --- /dev/null +++ b/apps/api/src/knowledge/dto/restore-version.dto.ts @@ -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; +} diff --git a/apps/api/src/knowledge/entities/knowledge-entry-version.entity.ts b/apps/api/src/knowledge/entities/knowledge-entry-version.entity.ts new file mode 100644 index 0000000..5a8ed6a --- /dev/null +++ b/apps/api/src/knowledge/entities/knowledge-entry-version.entity.ts @@ -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; + }; +} diff --git a/apps/api/src/knowledge/knowledge.controller.ts b/apps/api/src/knowledge/knowledge.controller.ts index 405c1c0..3ad6e8c 100644 --- a/apps/api/src/knowledge/knowledge.controller.ts +++ b/apps/api/src/knowledge/knowledge.controller.ts @@ -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 + ); + } } diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts index 09dd8cd..6c65bb3 100644 --- a/apps/api/src/knowledge/knowledge.service.ts +++ b/apps/api/src/knowledge/knowledge.service.ts @@ -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 { + // 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 { + // 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 { + // 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) */ diff --git a/apps/api/src/knowledge/knowledge.service.versions.spec.ts b/apps/api/src/knowledge/knowledge.service.versions.spec.ts new file mode 100644 index 0000000..ebbf779 --- /dev/null +++ b/apps/api/src/knowledge/knowledge.service.versions.spec.ts @@ -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: "

Test Content

", + 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); + prisma = module.get(PrismaService); + linkSync = module.get(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: "

Version 2

", + 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 + ); + }); + }); +}); diff --git a/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx index 6844174..9beb923 100644 --- a/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx +++ b/apps/web/src/app/(authenticated)/knowledge/[slug]/page.tsx @@ -7,7 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared"; import { EntryViewer } from "@/components/knowledge/EntryViewer"; import { EntryEditor } from "@/components/knowledge/EntryEditor"; import { EntryMetadata } from "@/components/knowledge/EntryMetadata"; -import { EntryGraphViewer } from "@/components/knowledge/EntryGraphViewer"; +import { VersionHistory } from "@/components/knowledge/VersionHistory"; import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge"; /** @@ -21,7 +21,6 @@ export default function EntryPage() { const [entry, setEntry] = useState(null); const [isEditing, setIsEditing] = useState(false); - const [showGraph, setShowGraph] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -34,6 +33,7 @@ export default function EntryPage() { const [editTags, setEditTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [activeTab, setActiveTab] = useState<"content" | "history">("content"); // Load entry data useEffect(() => { @@ -46,7 +46,7 @@ export default function EntryPage() { setEditContent(data.content); setEditStatus(data.status); setEditVisibility(data.visibility); - setEditTags(data.tags.map((tag: { id: string }) => tag.id)); + setEditTags(data.tags.map((tag) => tag.id)); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load entry"); } finally { @@ -82,7 +82,7 @@ export default function EntryPage() { editStatus !== entry.status || editVisibility !== entry.visibility || JSON.stringify(editTags.sort()) !== - JSON.stringify(entry.tags.map((t: { id: string }) => t.id).sort()); + JSON.stringify(entry.tags.map((t) => t.id).sort()); setHasUnsavedChanges(changed); }, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]); @@ -158,7 +158,7 @@ export default function EntryPage() { setEditContent(entry.content); setEditStatus(entry.status); setEditVisibility(entry.visibility); - setEditTags(entry.tags.map((tag: { id: string }) => tag.id)); + setEditTags(entry.tags.map((tag) => tag.id)); setIsEditing(false); setHasUnsavedChanges(false); } @@ -181,6 +181,25 @@ export default function EntryPage() { } }; + const handleVersionRestore = (): void => { + // Reload entry after version restore + async function reload(): Promise { + 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) { return (
@@ -250,7 +269,7 @@ export default function EntryPage() { {/* Tags */} - {entry.tags.map((tag: { id: string; name: string; color: string | null }) => ( + {entry.tags.map((tag) => ( )} - {/* View Tabs */} + {/* Tabs */} {!isEditing && ( -
-
+
+
+
)} @@ -302,12 +323,10 @@ export default function EntryPage() {
{isEditing ? ( - ) : showGraph ? ( -
- -
- ) : ( + ) : activeTab === "content" ? ( + ) : ( + )}
diff --git a/apps/web/src/components/knowledge/VersionHistory.tsx b/apps/web/src/components/knowledge/VersionHistory.tsx new file mode 100644 index 0000000..7d0825d --- /dev/null +++ b/apps/web/src/components/knowledge/VersionHistory.tsx @@ -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): React.JSX.Element { + const [versions, setVersions] = useState([]); + const [selectedVersion, setSelectedVersion] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isRestoring, setIsRestoring] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + + // Load versions + useEffect(() => { + async function loadVersions(): Promise { + 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 => { + 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 => { + 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 ( +
+
+
+
+
+
+
+ ); + } + + return ( +
+ {error && ( +
+

{error}

+
+ )} + + {versions.length === 0 ? ( +
+

No version history available

+
+ ) : ( + <> + {/* Version Timeline */} +
+ {versions.map((version, index) => ( +
+
+
+
+ + Version {version.version} + + {index === 0 && ( + + Current + + )} +
+

+ {version.author.name} ({version.author.email}) +

+

+ {formatDate(version.createdAt)} +

+ {version.changeNote && ( +

+ "{version.changeNote}" +

+ )} +

+ {version.title} +

+
+
+ + {index !== 0 && ( + + )} +
+
+ + {/* Version Preview */} + {selectedVersion?.id === version.id && ( +
+

+ Content Preview +

+
+
+                        {selectedVersion.content}
+                      
+
+
+ )} +
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/apps/web/src/components/knowledge/index.ts b/apps/web/src/components/knowledge/index.ts index 6719d71..1132045 100644 --- a/apps/web/src/components/knowledge/index.ts +++ b/apps/web/src/components/knowledge/index.ts @@ -1,8 +1,8 @@ -export { EntryCard } from "./EntryCard"; -export { EntryEditor } from "./EntryEditor"; -export { EntryFilters } from "./EntryFilters"; -export { EntryList } from "./EntryList"; -export { EntryMetadata } from "./EntryMetadata"; +/** + * Knowledge module components + */ + export { EntryViewer } from "./EntryViewer"; -export { StatsDashboard } from "./StatsDashboard"; -export { EntryGraphViewer } from "./EntryGraphViewer"; +export { EntryEditor } from "./EntryEditor"; +export { EntryMetadata } from "./EntryMetadata"; +export { VersionHistory } from "./VersionHistory"; diff --git a/apps/web/src/lib/api/knowledge.ts b/apps/web/src/lib/api/knowledge.ts index 5a429bb..b57ffb7 100644 --- a/apps/web/src/lib/api/knowledge.ts +++ b/apps/web/src/lib/api/knowledge.ts @@ -3,7 +3,12 @@ * 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 { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client"; @@ -44,6 +49,11 @@ export interface UpdateEntryData { status?: EntryStatus; visibility?: Visibility; tags?: string[]; + changeNote?: string; +} + +export interface RestoreVersionData { + changeNote?: string; } /** @@ -129,19 +139,46 @@ export async function fetchTags(): Promise { } /** - * Fetch entry-centered graph view + * Fetch version history for an entry */ -export async function fetchEntryGraph(slug: string, depth: number = 1) { +export async function fetchVersions( + slug: string, + page: number = 1, + limit: number = 20 +): Promise> { const params = new URLSearchParams(); - params.append("depth", depth.toString()); - return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`); + params.append("page", page.toString()); + params.append("limit", limit.toString()); + + return apiGet>( + `/api/knowledge/entries/${slug}/versions?${params.toString()}` + ); } /** - * Fetch knowledge base statistics + * Fetch a specific version of an entry */ -export async function fetchKnowledgeStats() { - return apiGet("/api/knowledge/stats"); +export async function fetchVersion( + slug: string, + version: number +): Promise { + return apiGet( + `/api/knowledge/entries/${slug}/versions/${version}` + ); +} + +/** + * Restore a previous version of an entry + */ +export async function restoreVersion( + slug: string, + version: number, + data?: RestoreVersionData +): Promise { + return apiPost( + `/api/knowledge/entries/${slug}/restore/${version}`, + data || {} + ); } /** diff --git a/docs/JARVIS_FE_MIGRATION.md b/docs/JARVIS_FE_MIGRATION.md new file mode 100644 index 0000000..721e76f --- /dev/null +++ b/docs/JARVIS_FE_MIGRATION.md @@ -0,0 +1,124 @@ +# Jarvis Frontend Migration Plan + +## Overview + +Cherry-pick high-value components from `mosaic/jarvis` into `mosaic/stack` to accelerate frontend development. + +**Source:** `~/src/jarvis-fe/apps/web/src/` +**Target:** `~/src/mosaic-stack/apps/web/src/` + +## Stack Compatibility ✅ + +| Aspect | Jarvis | Mosaic Stack | Compatible | +|--------|--------|--------------|------------| +| Next.js | 16.1.1 | 16.1.6 | ✅ | +| React | 19.2.0 | 19.0.0 | ✅ | +| TypeScript | ~5.x | 5.8.2 | ✅ | +| Tailwind | Yes | Yes | ✅ | +| Auth | better-auth | better-auth | ✅ | + +## Migration Phases + +### Phase 1: Dependencies (Pre-requisite) +Add missing packages to mosaic-stack: +```bash +pnpm add @xyflow/react elkjs mermaid @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities +``` + +### Phase 2: Core Infrastructure +| Component | Source | Target | Priority | +|-----------|--------|--------|----------| +| ThemeProvider.tsx | providers/ | providers/ | P0 | +| ThemeToggle.tsx | components/ | components/layout/ | P0 | +| globals.css (theme vars) | app/ | app/ | P0 | + +### Phase 3: Chat/Jarvis Overlay (#42) +| Component | Source | Target | Notes | +|-----------|--------|--------|-------| +| Chat.tsx | components/ | components/chat/ | Main chat UI | +| ChatInput.tsx | components/ | components/chat/ | Input with attachments | +| MessageList.tsx | components/ | components/chat/ | Message rendering | +| ConversationSidebar.tsx | components/ | components/chat/ | History panel | +| BackendStatusBanner.tsx | components/ | components/chat/ | Connection status | + +**Adaptation needed:** +- Update API endpoints to mosaic-stack backend +- Integrate with existing auth context +- Connect to Brain/Ideas API for semantic search + +### Phase 4: Mindmap/Visual Editor +| Component | Source | Target | Notes | +|-----------|--------|--------|-------| +| mindmap/ReactFlowEditor.tsx | components/ | components/mindmap/ | Main editor | +| mindmap/MindmapViewer.tsx | components/ | components/mindmap/ | Read-only view | +| mindmap/MermaidViewer.tsx | components/ | components/mindmap/ | Mermaid diagrams | +| mindmap/nodes/*.tsx | components/ | components/mindmap/nodes/ | Custom node types | +| mindmap/controls/*.tsx | components/ | components/mindmap/controls/ | Toolbar/export | + +**Adaptation needed:** +- Connect to Knowledge module for entries +- Map node types to Mosaic entities (Task, Idea, Project) +- Update save/load to use Mosaic API + +### Phase 5: Admin/Settings Enhancement +| Component | Source | Target | Notes | +|-----------|--------|--------|-------| +| admin/Header.tsx | components/ | components/admin/ | Already exists, compare | +| admin/Sidebar.tsx | components/ | components/admin/ | Already exists, compare | +| HeaderMenu.tsx | components/ | components/layout/ | Navigation dropdown | +| HeaderActions.tsx | components/ | components/layout/ | Quick actions | + +**Action:** Compare and merge best patterns from both. + +### Phase 6: Integrations +| Component | Source | Target | Notes | +|-----------|--------|--------|-------| +| integrations/OAuthButton.tsx | components/ | components/integrations/ | OAuth flow UI | +| settings/integrations/page.tsx | app/ | app/ | Integration settings | + +## Execution Plan + +### Agent 1: Dependencies & Theme (15 min) +- Add missing npm packages +- Copy theme infrastructure +- Verify dark/light mode works + +### Agent 2: Chat Components (30 min) +- Copy chat components +- Update imports and paths +- Adapt API calls to mosaic-stack endpoints +- Create placeholder chat route + +### Agent 3: Mindmap Components (30 min) +- Copy mindmap components +- Update imports and paths +- Connect to Knowledge API +- Create mindmap route + +### Agent 4: Polish & Integration (20 min) +- Code review all copied components +- Fix TypeScript errors +- Update component exports +- Test basic functionality + +## Files to Skip (Already Better in Mosaic) +- kanban/* (already implemented with tests) +- Most app/ routes (different structure) +- Auth providers (already configured) + +## Success Criteria +1. ✅ Theme toggle works (dark/light) +2. ✅ Chat UI renders (even if not connected) +3. ✅ Mindmap editor loads with ReactFlow +4. ✅ No TypeScript errors +5. ✅ Build passes + +## Risks +- **API mismatch:** Jarvis uses different API structure — need adapter layer +- **State management:** May need to reconcile different patterns +- **Styling conflicts:** CSS variable names may differ + +## Notes +- Keep jarvis-fe repo for reference, don't modify it +- All work in mosaic-stack on feature branch +- Create PR for review before merge diff --git a/packages/shared/src/types/database.types.ts b/packages/shared/src/types/database.types.ts index 4f6cd8b..3f3a60f 100644 --- a/packages/shared/src/types/database.types.ts +++ b/packages/shared/src/types/database.types.ts @@ -185,6 +185,32 @@ export interface KnowledgeEntryWithTags extends KnowledgeEntry { 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 */ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..a70f796 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,15 @@ packages: - - "apps/*" - - "packages/*" + - apps/* + - packages/* + +ignoredBuiltDependencies: + - '@nestjs/core' + - '@swc/core' + - better-sqlite3 + - esbuild + - sharp + +onlyBuiltDependencies: + - '@prisma/client' + - '@prisma/engines' + - prisma