diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a6930ec..ad47814 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,7 +15,7 @@ import { LayoutsModule } from "./layouts/layouts.module"; import { KnowledgeModule } from "./knowledge/knowledge.module"; import { UsersModule } from "./users/users.module"; import { WebSocketModule } from "./websocket/websocket.module"; -import { OllamaModule } from "./ollama/ollama.module"; +import { LlmModule } from "./llm/llm.module"; import { BrainModule } from "./brain/brain.module"; @Module({ @@ -34,7 +34,7 @@ import { BrainModule } from "./brain/brain.module"; KnowledgeModule, UsersModule, WebSocketModule, - OllamaModule, + LlmModule, BrainModule, ], controllers: [AppController], diff --git a/apps/api/src/knowledge/services/index.ts b/apps/api/src/knowledge/services/index.ts index 94b803f..ed6384e 100644 --- a/apps/api/src/knowledge/services/index.ts +++ b/apps/api/src/knowledge/services/index.ts @@ -1,2 +1,6 @@ export { LinkResolutionService } from "./link-resolution.service"; -export type { ResolvedEntry } from "./link-resolution.service"; +export type { + ResolvedEntry, + ResolvedLink, + Backlink, +} from "./link-resolution.service"; diff --git a/apps/api/src/knowledge/services/link-resolution.service.spec.ts b/apps/api/src/knowledge/services/link-resolution.service.spec.ts index cf23a27..629f834 100644 --- a/apps/api/src/knowledge/services/link-resolution.service.spec.ts +++ b/apps/api/src/knowledge/services/link-resolution.service.spec.ts @@ -63,6 +63,9 @@ describe("LinkResolutionService", () => { findFirst: vi.fn(), findMany: vi.fn(), }, + knowledgeLink: { + findMany: vi.fn(), + }, }; beforeEach(async () => { @@ -403,4 +406,186 @@ describe("LinkResolutionService", () => { expect(result).toHaveLength(1); }); }); + + describe("resolveLinksFromContent", () => { + it("should parse and resolve wiki links from content", async () => { + const content = + "Check out [[TypeScript Guide]] and [[React Hooks]] for more info."; + + // Mock resolveLink for each target + mockPrismaService.knowledgeEntry.findFirst + .mockResolvedValueOnce({ id: "entry-1" }) // TypeScript Guide + .mockResolvedValueOnce({ id: "entry-2" }); // React Hooks + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(2); + expect(result[0].link.target).toBe("TypeScript Guide"); + expect(result[0].entryId).toBe("entry-1"); + expect(result[1].link.target).toBe("React Hooks"); + expect(result[1].entryId).toBe("entry-2"); + }); + + it("should return null entryId for unresolved links", async () => { + const content = "See [[Non-existent Page]] for details."; + + mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findMany.mockResolvedValueOnce([]); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(1); + expect(result[0].link.target).toBe("Non-existent Page"); + expect(result[0].entryId).toBeNull(); + }); + + it("should return empty array for content with no wiki links", async () => { + const content = "This content has no wiki links."; + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toEqual([]); + expect(mockPrismaService.knowledgeEntry.findFirst).not.toHaveBeenCalled(); + }); + + it("should handle content with display text syntax", async () => { + const content = "Read the [[typescript-guide|TS Guide]] first."; + + mockPrismaService.knowledgeEntry.findFirst.mockResolvedValueOnce(null); + mockPrismaService.knowledgeEntry.findUnique.mockResolvedValueOnce({ + id: "entry-1", + }); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(1); + expect(result[0].link.target).toBe("typescript-guide"); + expect(result[0].link.displayText).toBe("TS Guide"); + expect(result[0].entryId).toBe("entry-1"); + }); + + it("should preserve link position information", async () => { + const content = "Start [[Page One]] middle [[Page Two]] end."; + + mockPrismaService.knowledgeEntry.findFirst + .mockResolvedValueOnce({ id: "entry-1" }) + .mockResolvedValueOnce({ id: "entry-2" }); + + const result = await service.resolveLinksFromContent(content, workspaceId); + + expect(result).toHaveLength(2); + expect(result[0].link.start).toBe(6); + expect(result[0].link.end).toBe(18); + expect(result[1].link.start).toBe(26); + expect(result[1].link.end).toBe(38); + }); + }); + + describe("getBacklinks", () => { + it("should return all entries that link to the target entry", async () => { + const targetEntryId = "entry-target"; + const mockBacklinks = [ + { + id: "link-1", + sourceId: "entry-1", + targetId: targetEntryId, + linkText: "Target Page", + displayText: "Target Page", + positionStart: 10, + positionEnd: 25, + resolved: true, + context: null, + createdAt: new Date(), + source: { + id: "entry-1", + title: "TypeScript Guide", + slug: "typescript-guide", + }, + }, + { + id: "link-2", + sourceId: "entry-2", + targetId: targetEntryId, + linkText: "Target Page", + displayText: "See Target", + positionStart: 50, + positionEnd: 70, + resolved: true, + context: null, + createdAt: new Date(), + source: { + id: "entry-2", + title: "React Hooks", + slug: "react-hooks", + }, + }, + ]; + + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce( + mockBacklinks + ); + + const result = await service.getBacklinks(targetEntryId); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + sourceId: "entry-1", + sourceTitle: "TypeScript Guide", + sourceSlug: "typescript-guide", + linkText: "Target Page", + displayText: "Target Page", + }); + expect(result[1]).toEqual({ + sourceId: "entry-2", + sourceTitle: "React Hooks", + sourceSlug: "react-hooks", + linkText: "Target Page", + displayText: "See Target", + }); + + expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith({ + where: { + targetId: targetEntryId, + resolved: true, + }, + include: { + source: { + select: { + id: true, + title: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + }); + + it("should return empty array when no backlinks exist", async () => { + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]); + + const result = await service.getBacklinks("entry-with-no-backlinks"); + + expect(result).toEqual([]); + }); + + it("should only return resolved backlinks", async () => { + const targetEntryId = "entry-target"; + + mockPrismaService.knowledgeLink.findMany.mockResolvedValueOnce([]); + + await service.getBacklinks(targetEntryId); + + expect(mockPrismaService.knowledgeLink.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + resolved: true, + }), + }) + ); + }); + }); }); diff --git a/apps/api/src/knowledge/services/link-resolution.service.ts b/apps/api/src/knowledge/services/link-resolution.service.ts index e869100..f00129f 100644 --- a/apps/api/src/knowledge/services/link-resolution.service.ts +++ b/apps/api/src/knowledge/services/link-resolution.service.ts @@ -1,5 +1,6 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; +import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser"; /** * Represents a knowledge entry that matches a link target @@ -9,6 +10,32 @@ export interface ResolvedEntry { title: string; } +/** + * Represents a resolved wiki link with entry information + */ +export interface ResolvedLink { + /** The parsed wiki link */ + link: WikiLink; + /** The resolved entry ID, or null if not found */ + entryId: string | null; +} + +/** + * Represents a backlink - an entry that links to a target entry + */ +export interface Backlink { + /** The source entry ID */ + sourceId: string; + /** The source entry title */ + sourceTitle: string; + /** The source entry slug */ + sourceSlug: string; + /** The link text used to reference the target */ + linkText: string; + /** The display text shown for the link */ + displayText: string; +} + /** * Service for resolving wiki-style links to knowledge entries * @@ -165,4 +192,72 @@ export class LinkResolutionService { return matches; } + + /** + * Parse wiki links from content and resolve them to knowledge entries + * + * @param content - The markdown content containing wiki links + * @param workspaceId - The workspace scope for resolution + * @returns Array of resolved links with entry IDs (or null if not found) + */ + async resolveLinksFromContent( + content: string, + workspaceId: string + ): Promise { + // Parse wiki links from content + const parsedLinks = parseWikiLinks(content); + + if (parsedLinks.length === 0) { + return []; + } + + // Resolve each link + const resolvedLinks: ResolvedLink[] = []; + + for (const link of parsedLinks) { + const entryId = await this.resolveLink(workspaceId, link.target); + resolvedLinks.push({ + link, + entryId, + }); + } + + return resolvedLinks; + } + + /** + * Get all entries that link TO a specific entry (backlinks) + * + * @param entryId - The target entry ID + * @returns Array of backlinks with source entry information + */ + async getBacklinks(entryId: string): Promise { + // Find all links where this entry is the target + const links = await this.prisma.knowledgeLink.findMany({ + where: { + targetId: entryId, + resolved: true, + }, + include: { + source: { + select: { + id: true, + title: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return links.map((link) => ({ + sourceId: link.source.id, + sourceTitle: link.source.title, + sourceSlug: link.source.slug, + linkText: link.linkText, + displayText: link.displayText, + })); + } } diff --git a/apps/api/src/llm/dto/chat.dto.ts b/apps/api/src/llm/dto/chat.dto.ts new file mode 100644 index 0000000..d2e5a80 --- /dev/null +++ b/apps/api/src/llm/dto/chat.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsString, IsOptional, IsBoolean, IsNumber, ValidateNested, IsIn } from "class-validator"; +import { Type } from "class-transformer"; +export type ChatRole = "system" | "user" | "assistant"; +export class ChatMessageDto { @IsString() @IsIn(["system", "user", "assistant"]) role!: ChatRole; @IsString() content!: string; } +export class ChatRequestDto { @IsString() model!: string; @IsArray() @ValidateNested({ each: true }) @Type(() => ChatMessageDto) messages!: ChatMessageDto[]; @IsOptional() @IsBoolean() stream?: boolean; @IsOptional() @IsNumber() temperature?: number; @IsOptional() @IsNumber() maxTokens?: number; @IsOptional() @IsString() systemPrompt?: string; } +export interface ChatResponseDto { model: string; message: { role: ChatRole; content: string }; done: boolean; totalDuration?: number; promptEvalCount?: number; evalCount?: number; } +export interface ChatStreamChunkDto { model: string; message: { role: ChatRole; content: string }; done: boolean; } diff --git a/apps/api/src/llm/dto/embed.dto.ts b/apps/api/src/llm/dto/embed.dto.ts new file mode 100644 index 0000000..6f017c6 --- /dev/null +++ b/apps/api/src/llm/dto/embed.dto.ts @@ -0,0 +1,3 @@ +import { IsArray, IsString, IsOptional } from "class-validator"; +export class EmbedRequestDto { @IsString() model!: string; @IsArray() @IsString({ each: true }) input!: string[]; @IsOptional() @IsString() truncate?: "start" | "end" | "none"; } +export interface EmbedResponseDto { model: string; embeddings: number[][]; totalDuration?: number; } diff --git a/apps/api/src/llm/dto/index.ts b/apps/api/src/llm/dto/index.ts new file mode 100644 index 0000000..e0ed4eb --- /dev/null +++ b/apps/api/src/llm/dto/index.ts @@ -0,0 +1,2 @@ +export * from "./chat.dto"; +export * from "./embed.dto"; diff --git a/apps/api/src/llm/index.ts b/apps/api/src/llm/index.ts new file mode 100644 index 0000000..4a101fa --- /dev/null +++ b/apps/api/src/llm/index.ts @@ -0,0 +1,4 @@ +export * from "./llm.module"; +export * from "./llm.service"; +export * from "./llm.controller"; +export * from "./dto"; diff --git a/apps/api/src/llm/llm.controller.spec.ts b/apps/api/src/llm/llm.controller.spec.ts new file mode 100644 index 0000000..7822f38 --- /dev/null +++ b/apps/api/src/llm/llm.controller.spec.ts @@ -0,0 +1,15 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { LlmController } from "./llm.controller"; +import { LlmService } from "./llm.service"; +import type { ChatRequestDto, EmbedRequestDto } from "./dto"; +describe("LlmController", () => { + let controller: LlmController; + const mockService = { checkHealth: vi.fn(), listModels: vi.fn(), chat: vi.fn(), chatStream: vi.fn(), embed: vi.fn() }; + beforeEach(async () => { vi.clearAllMocks(); controller = (await Test.createTestingModule({ controllers: [LlmController], providers: [{ provide: LlmService, useValue: mockService }] }).compile()).get(LlmController); }); + it("should be defined", () => { expect(controller).toBeDefined(); }); + describe("health", () => { it("should return status", async () => { const s = { healthy: true, host: "h" }; mockService.checkHealth.mockResolvedValue(s); expect(await controller.health()).toEqual(s); }); }); + describe("listModels", () => { it("should return models", async () => { mockService.listModels.mockResolvedValue(["m1"]); expect(await controller.listModels()).toEqual({ models: ["m1"] }); }); }); + describe("chat", () => { const req: ChatRequestDto = { model: "m", messages: [{ role: "user", content: "x" }] }; const res = { setHeader: vi.fn(), write: vi.fn(), end: vi.fn() }; it("should return response", async () => { const r = { model: "m", message: { role: "assistant", content: "y" }, done: true }; mockService.chat.mockResolvedValue(r); expect(await controller.chat(req, res as any)).toEqual(r); }); it("should stream", async () => { mockService.chatStream.mockReturnValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); await controller.chat({ ...req, stream: true }, res as any); expect(res.setHeader).toHaveBeenCalled(); expect(res.end).toHaveBeenCalled(); }); }); + describe("embed", () => { it("should return embeddings", async () => { const r = { model: "m", embeddings: [[0.1]] }; mockService.embed.mockResolvedValue(r); expect(await controller.embed({ model: "m", input: ["x"] })).toEqual(r); }); }); +}); diff --git a/apps/api/src/llm/llm.controller.ts b/apps/api/src/llm/llm.controller.ts new file mode 100644 index 0000000..b55c4ef --- /dev/null +++ b/apps/api/src/llm/llm.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Post, Get, Body, Res, HttpCode, HttpStatus } from "@nestjs/common"; +import { Response } from "express"; +import { LlmService, OllamaHealthStatus } from "./llm.service"; +import { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto } from "./dto"; +@Controller("llm") +export class LlmController { + constructor(private readonly llmService: LlmService) {} + @Get("health") async health(): Promise { return this.llmService.checkHealth(); } + @Get("models") async listModels(): Promise<{ models: string[] }> { return { models: await this.llmService.listModels() }; } + @Post("chat") @HttpCode(HttpStatus.OK) async chat(@Body() req: ChatRequestDto, @Res({ passthrough: true }) res: Response): Promise { if (req.stream === true) { res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); try { for await (const c of this.llmService.chatStream(req)) res.write("data: " + JSON.stringify(c) + "\n\n"); res.write("data: [DONE]\n\n"); res.end(); } catch (e: unknown) { res.write("data: " + JSON.stringify({ error: e instanceof Error ? e.message : String(e) }) + "\n\n"); res.end(); } return; } return this.llmService.chat(req); } + @Post("embed") @HttpCode(HttpStatus.OK) async embed(@Body() req: EmbedRequestDto): Promise { return this.llmService.embed(req); } +} diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts new file mode 100644 index 0000000..3aab60e --- /dev/null +++ b/apps/api/src/llm/llm.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@nestjs/common"; +import { LlmController } from "./llm.controller"; +import { LlmService } from "./llm.service"; +@Module({ controllers: [LlmController], providers: [LlmService], exports: [LlmService] }) +export class LlmModule {} diff --git a/apps/api/src/llm/llm.service.spec.ts b/apps/api/src/llm/llm.service.spec.ts new file mode 100644 index 0000000..4b262b7 --- /dev/null +++ b/apps/api/src/llm/llm.service.spec.ts @@ -0,0 +1,19 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { ServiceUnavailableException } from "@nestjs/common"; +import { LlmService } from "./llm.service"; +import type { ChatRequestDto, EmbedRequestDto } from "./dto"; +const mockList = vi.fn(); const mockChat = vi.fn(); const mockEmbed = vi.fn(); +vi.mock("ollama", () => ({ Ollama: class { list = mockList; chat = mockChat; embed = mockEmbed; } })); +describe("LlmService", () => { + let service: LlmService; + const originalEnv = { ...process.env }; + beforeEach(async () => { process.env = { ...originalEnv, OLLAMA_HOST: "http://test:11434", OLLAMA_TIMEOUT: "60000" }; vi.clearAllMocks(); service = (await Test.createTestingModule({ providers: [LlmService] }).compile()).get(LlmService); }); + afterEach(() => { process.env = originalEnv; }); + it("should be defined", () => { expect(service).toBeDefined(); }); + describe("checkHealth", () => { it("should return healthy", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); const r = await service.checkHealth(); expect(r.healthy).toBe(true); }); it("should return unhealthy on error", async () => { mockList.mockRejectedValue(new Error("fail")); const r = await service.checkHealth(); expect(r.healthy).toBe(false); }); }); + describe("listModels", () => { it("should return models", async () => { mockList.mockResolvedValue({ models: [{ name: "llama3.2" }] }); expect(await service.listModels()).toEqual(["llama3.2"]); }); it("should throw on error", async () => { mockList.mockRejectedValue(new Error("fail")); await expect(service.listModels()).rejects.toThrow(ServiceUnavailableException); }); }); + describe("chat", () => { const req: ChatRequestDto = { model: "llama3.2", messages: [{ role: "user", content: "Hi" }] }; it("should return response", async () => { mockChat.mockResolvedValue({ model: "llama3.2", message: { role: "assistant", content: "Hello" }, done: true }); const r = await service.chat(req); expect(r.message.content).toBe("Hello"); }); it("should throw on error", async () => { mockChat.mockRejectedValue(new Error("fail")); await expect(service.chat(req)).rejects.toThrow(ServiceUnavailableException); }); }); + describe("chatStream", () => { it("should yield chunks", async () => { mockChat.mockResolvedValue((async function* () { yield { model: "m", message: { role: "a", content: "x" }, done: true }; })()); const chunks = []; for await (const c of service.chatStream({ model: "m", messages: [{ role: "user", content: "x" }], stream: true })) chunks.push(c); expect(chunks.length).toBe(1); }); }); + describe("embed", () => { it("should return embeddings", async () => { mockEmbed.mockResolvedValue({ model: "m", embeddings: [[0.1]] }); const r = await service.embed({ model: "m", input: ["x"] }); expect(r.embeddings).toEqual([[0.1]]); }); }); +}); diff --git a/apps/api/src/llm/llm.service.ts b/apps/api/src/llm/llm.service.ts new file mode 100644 index 0000000..10f32e4 --- /dev/null +++ b/apps/api/src/llm/llm.service.ts @@ -0,0 +1,20 @@ +import { Injectable, OnModuleInit, Logger, ServiceUnavailableException } from "@nestjs/common"; +import { Ollama, Message } from "ollama"; +import type { ChatRequestDto, ChatResponseDto, EmbedRequestDto, EmbedResponseDto, ChatStreamChunkDto } from "./dto"; +export interface OllamaConfig { host: string; timeout?: number; } +export interface OllamaHealthStatus { healthy: boolean; host: string; error?: string; models?: string[]; } +@Injectable() +export class LlmService implements OnModuleInit { + private readonly logger = new Logger(LlmService.name); + private client: Ollama; + private readonly config: OllamaConfig; + constructor() { this.config = { host: process.env["OLLAMA_HOST"] ?? "http://localhost:11434", timeout: parseInt(process.env["OLLAMA_TIMEOUT"] ?? "120000", 10) }; this.client = new Ollama({ host: this.config.host }); this.logger.log("Ollama service initialized"); } + async onModuleInit(): Promise { const h = await this.checkHealth(); if (h.healthy) this.logger.log("Ollama healthy"); else this.logger.warn("Ollama unhealthy: " + (h.error ?? "unknown")); } + async checkHealth(): Promise { try { const r = await this.client.list(); return { healthy: true, host: this.config.host, models: r.models.map(m => m.name) }; } catch (e: unknown) { return { healthy: false, host: this.config.host, error: e instanceof Error ? e.message : String(e) }; } } + async listModels(): Promise { try { return (await this.client.list()).models.map(m => m.name); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Failed to list models: " + msg); throw new ServiceUnavailableException("Failed to list models: " + msg); } } + async chat(request: ChatRequestDto): Promise { try { const msgs = this.buildMessages(request); const r = await this.client.chat({ model: request.model, messages: msgs, stream: false, options: { temperature: request.temperature, num_predict: request.maxTokens } }); return { model: r.model, message: { role: r.message.role as "assistant", content: r.message.content }, done: r.done, totalDuration: r.total_duration, promptEvalCount: r.prompt_eval_count, evalCount: r.eval_count }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Chat failed: " + msg); throw new ServiceUnavailableException("Chat completion failed: " + msg); } } + async *chatStream(request: ChatRequestDto): AsyncGenerator { try { const stream = await this.client.chat({ model: request.model, messages: this.buildMessages(request), stream: true, options: { temperature: request.temperature, num_predict: request.maxTokens } }); for await (const c of stream) yield { model: c.model, message: { role: c.message.role as "assistant", content: c.message.content }, done: c.done }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Stream failed: " + msg); throw new ServiceUnavailableException("Streaming failed: " + msg); } } + async embed(request: EmbedRequestDto): Promise { try { const r = await this.client.embed({ model: request.model, input: request.input, truncate: request.truncate === "none" ? false : true }); return { model: r.model, embeddings: r.embeddings, totalDuration: r.total_duration }; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.logger.error("Embed failed: " + msg); throw new ServiceUnavailableException("Embedding failed: " + msg); } } + private buildMessages(req: ChatRequestDto): Message[] { const msgs: Message[] = []; if (req.systemPrompt && !req.messages.some(m => m.role === "system")) msgs.push({ role: "system", content: req.systemPrompt }); for (const m of req.messages) msgs.push({ role: m.role, content: m.content }); return msgs; } + getConfig(): OllamaConfig { return { ...this.config }; } +} diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts index 1ccd6b0..29a412b 100644 --- a/apps/api/src/personalities/dto/update-personality.dto.ts +++ b/apps/api/src/personalities/dto/update-personality.dto.ts @@ -1,4 +1,38 @@ -import { PartialType } from "@nestjs/mapped-types"; -import { CreatePersonalityDto } from "./create-personality.dto"; +import { IsString, IsOptional, IsBoolean, MinLength, MaxLength, IsIn } from "class-validator"; +import { FORMALITY_LEVELS, FormalityLevelType } from "./create-personality.dto"; -export class UpdatePersonalityDto extends PartialType(CreatePersonalityDto) {} +export class UpdatePersonalityDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(50) + tone?: string; + + @IsOptional() + @IsIn(FORMALITY_LEVELS) + formalityLevel?: FormalityLevelType; + + @IsOptional() + @IsString() + @MinLength(10) + systemPromptTemplate?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts index 1092d35..9f58130 100644 --- a/apps/api/src/personalities/personalities.controller.spec.ts +++ b/apps/api/src/personalities/personalities.controller.spec.ts @@ -2,29 +2,24 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesService } from "./personalities.service"; +import { PromptFormatterService } from "./services/prompt-formatter.service"; import { AuthGuard } from "../auth/guards/auth.guard"; -import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; describe("PersonalitiesController", () => { let controller: PersonalitiesController; let service: PersonalitiesService; + let promptFormatter: PromptFormatterService; const mockWorkspaceId = "workspace-123"; - const mockUserId = "user-123"; const mockPersonalityId = "personality-123"; - - const mockRequest = { - user: { id: mockUserId }, - workspaceId: mockWorkspaceId, - }; + const mockRequest = { user: { id: "user-123" }, workspaceId: mockWorkspaceId }; const mockPersonality = { id: mockPersonalityId, workspaceId: mockWorkspaceId, name: "Professional", - description: "Professional communication style", tone: "professional", - formalityLevel: "FORMAL" as const, + formalityLevel: "FORMAL", systemPromptTemplate: "You are a professional assistant.", isDefault: true, isActive: true, @@ -41,105 +36,82 @@ describe("PersonalitiesController", () => { remove: vi.fn(), }; - const mockAuthGuard = { - canActivate: vi.fn().mockReturnValue(true), + const mockPromptFormatterService = { + formatPrompt: vi.fn(), + getFormalityLevels: vi.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PersonalitiesController], providers: [ - { - provide: PersonalitiesService, - useValue: mockPersonalitiesService, - }, + { provide: PersonalitiesService, useValue: mockPersonalitiesService }, + { provide: PromptFormatterService, useValue: mockPromptFormatterService }, ], }) .overrideGuard(AuthGuard) - .useValue(mockAuthGuard) + .useValue({ canActivate: () => true }) .compile(); controller = module.get(PersonalitiesController); service = module.get(PersonalitiesService); - - // Reset mocks + promptFormatter = module.get(PromptFormatterService); vi.clearAllMocks(); }); describe("findAll", () => { it("should return all personalities", async () => { - const mockPersonalities = [mockPersonality]; - mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities); - - const result = await controller.findAll(mockRequest as any); - - expect(result).toEqual(mockPersonalities); - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, true); - }); - - it("should filter by active status", async () => { mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]); - - await controller.findAll(mockRequest as any, false); - - expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, false); + const result = await controller.findAll(mockRequest); + expect(result).toEqual([mockPersonality]); + expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, undefined); }); }); describe("findOne", () => { it("should return a personality by id", async () => { mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); - - const result = await controller.findOne(mockRequest as any, mockPersonalityId); - + const result = await controller.findOne(mockRequest, mockPersonalityId); expect(result).toEqual(mockPersonality); - expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); }); describe("findDefault", () => { it("should return the default personality", async () => { mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality); - - const result = await controller.findDefault(mockRequest as any); - + const result = await controller.findDefault(mockRequest); expect(result).toEqual(mockPersonality); - expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId); + }); + }); + + describe("getFormalityLevels", () => { + it("should return formality levels", () => { + const levels = [{ level: "FORMAL", description: "Professional" }]; + mockPromptFormatterService.getFormalityLevels.mockReturnValue(levels); + const result = controller.getFormalityLevels(); + expect(result).toEqual(levels); }); }); describe("create", () => { - const createDto: CreatePersonalityDto = { - name: "Casual", - description: "Casual communication style", - tone: "casual", - formalityLevel: "CASUAL", - systemPromptTemplate: "You are a casual assistant.", - }; - it("should create a new personality", async () => { - const newPersonality = { ...mockPersonality, ...createDto, id: "new-id" }; - mockPersonalitiesService.create.mockResolvedValue(newPersonality); - - const result = await controller.create(mockRequest as any, createDto); - - expect(result).toEqual(newPersonality); + const createDto = { + name: "Casual", + tone: "casual", + formalityLevel: "CASUAL" as const, + systemPromptTemplate: "You are a casual assistant.", + }; + mockPersonalitiesService.create.mockResolvedValue({ ...mockPersonality, ...createDto }); + await controller.create(mockRequest, createDto); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto); }); }); describe("update", () => { - const updateDto: UpdatePersonalityDto = { - description: "Updated description", - }; - it("should update a personality", async () => { - const updatedPersonality = { ...mockPersonality, ...updateDto }; - mockPersonalitiesService.update.mockResolvedValue(updatedPersonality); - - const result = await controller.update(mockRequest as any, mockPersonalityId, updateDto); - - expect(result).toEqual(updatedPersonality); + const updateDto = { description: "Updated" }; + mockPersonalitiesService.update.mockResolvedValue({ ...mockPersonality, ...updateDto }); + await controller.update(mockRequest, mockPersonalityId, updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); }); }); @@ -147,11 +119,21 @@ describe("PersonalitiesController", () => { describe("remove", () => { it("should delete a personality", async () => { mockPersonalitiesService.remove.mockResolvedValue(mockPersonality); - - const result = await controller.remove(mockRequest as any, mockPersonalityId); - - expect(result).toEqual(mockPersonality); + await controller.remove(mockRequest, mockPersonalityId); expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); }); }); + + describe("previewPrompt", () => { + it("should return formatted system prompt", async () => { + const context = { userName: "John" }; + mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); + mockPromptFormatterService.formatPrompt.mockReturnValue({ + systemPrompt: "Formatted prompt", + metadata: {}, + }); + const result = await controller.previewPrompt(mockRequest, mockPersonalityId, context); + expect(result.systemPrompt).toBe("Formatted prompt"); + }); + }); }); diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts index dc53ce3..345d772 100644 --- a/apps/api/src/personalities/personalities.controller.ts +++ b/apps/api/src/personalities/personalities.controller.ts @@ -9,69 +9,81 @@ import { Query, UseGuards, Req, + ParseBoolPipe, + HttpCode, + HttpStatus, } from "@nestjs/common"; import { AuthGuard } from "../auth/guards/auth.guard"; import { PersonalitiesService } from "./personalities.service"; +import { PromptFormatterService, PromptContext } from "./services/prompt-formatter.service"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto"; import { Personality } from "./entities/personality.entity"; +interface AuthenticatedRequest { + user: { id: string }; + workspaceId: string; +} + @Controller("personalities") @UseGuards(AuthGuard) export class PersonalitiesController { - constructor(private readonly personalitiesService: PersonalitiesService) {} + constructor( + private readonly personalitiesService: PersonalitiesService, + private readonly promptFormatter: PromptFormatterService, + ) {} - /** - * Get all personalities for the current workspace - */ @Get() async findAll( - @Req() req: any, - @Query("isActive") isActive: boolean = true, + @Req() req: AuthenticatedRequest, + @Query("isActive", new ParseBoolPipe({ optional: true })) isActive?: boolean, ): Promise { return this.personalitiesService.findAll(req.workspaceId, isActive); } - /** - * Get the default personality for the current workspace - */ @Get("default") - async findDefault(@Req() req: any): Promise { + async findDefault(@Req() req: AuthenticatedRequest): Promise { return this.personalitiesService.findDefault(req.workspaceId); } - /** - * Get a specific personality by ID - */ + @Get("formality-levels") + getFormalityLevels(): Array<{ level: string; description: string }> { + return this.promptFormatter.getFormalityLevels(); + } + @Get(":id") - async findOne(@Req() req: any, @Param("id") id: string): Promise { + async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { return this.personalitiesService.findOne(req.workspaceId, id); } - /** - * Create a new personality - */ @Post() - async create(@Req() req: any, @Body() dto: CreatePersonalityDto): Promise { + @HttpCode(HttpStatus.CREATED) + async create(@Req() req: AuthenticatedRequest, @Body() dto: CreatePersonalityDto): Promise { return this.personalitiesService.create(req.workspaceId, dto); } - /** - * Update an existing personality - */ @Put(":id") async update( - @Req() req: any, + @Req() req: AuthenticatedRequest, @Param("id") id: string, @Body() dto: UpdatePersonalityDto, ): Promise { return this.personalitiesService.update(req.workspaceId, id, dto); } - /** - * Delete a personality - */ @Delete(":id") - async remove(@Req() req: any, @Param("id") id: string): Promise { + @HttpCode(HttpStatus.OK) + async remove(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise { return this.personalitiesService.remove(req.workspaceId, id); } + + @Post(":id/preview") + async previewPrompt( + @Req() req: AuthenticatedRequest, + @Param("id") id: string, + @Body() context?: PromptContext, + ): Promise<{ systemPrompt: string }> { + const personality = await this.personalitiesService.findOne(req.workspaceId, id); + const { systemPrompt } = this.promptFormatter.formatPrompt(personality, context); + return { systemPrompt }; + } } diff --git a/apps/api/src/personalities/personalities.module.ts b/apps/api/src/personalities/personalities.module.ts index 055b073..92ad013 100644 --- a/apps/api/src/personalities/personalities.module.ts +++ b/apps/api/src/personalities/personalities.module.ts @@ -3,11 +3,12 @@ import { PrismaModule } from "../prisma/prisma.module"; import { AuthModule } from "../auth/auth.module"; import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesController } from "./personalities.controller"; +import { PromptFormatterService } from "./services/prompt-formatter.service"; @Module({ imports: [PrismaModule, AuthModule], controllers: [PersonalitiesController], - providers: [PersonalitiesService], - exports: [PersonalitiesService], + providers: [PersonalitiesService, PromptFormatterService], + exports: [PersonalitiesService, PromptFormatterService], }) export class PersonalitiesModule {} diff --git a/apps/api/src/personalities/services/index.ts b/apps/api/src/personalities/services/index.ts new file mode 100644 index 0000000..0167b66 --- /dev/null +++ b/apps/api/src/personalities/services/index.ts @@ -0,0 +1 @@ +export * from "./prompt-formatter.service"; diff --git a/apps/api/src/personalities/services/prompt-formatter.service.spec.ts b/apps/api/src/personalities/services/prompt-formatter.service.spec.ts new file mode 100644 index 0000000..e6f0556 --- /dev/null +++ b/apps/api/src/personalities/services/prompt-formatter.service.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PromptFormatterService, PromptContext } from "./prompt-formatter.service"; + +describe("PromptFormatterService", () => { + let service: PromptFormatterService; + + const mockPersonality = { + id: "personality-123", + workspaceId: "workspace-123", + name: "Professional", + description: "Professional communication style", + tone: "professional", + formalityLevel: "FORMAL" as const, + systemPromptTemplate: "You are a helpful assistant for {{userName}} at {{workspaceName}}.", + isDefault: true, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PromptFormatterService], + }).compile(); + + service = module.get(PromptFormatterService); + }); + + describe("formatPrompt", () => { + it("should format prompt with context variables", () => { + const context: PromptContext = { userName: "John", workspaceName: "Acme Corp" }; + const result = service.formatPrompt(mockPersonality, context); + expect(result.systemPrompt).toContain("John"); + expect(result.systemPrompt).toContain("Acme Corp"); + expect(result.metadata.personalityId).toBe(mockPersonality.id); + }); + + it("should add formality modifier", () => { + const result = service.formatPrompt(mockPersonality); + expect(result.systemPrompt).toContain("professional"); + }); + + it("should include metadata", () => { + const result = service.formatPrompt(mockPersonality); + expect(result.metadata.formattedAt).toBeInstanceOf(Date); + expect(result.metadata.tone).toBe("professional"); + }); + + it("should handle custom context variables", () => { + const personality = { ...mockPersonality, systemPromptTemplate: "Custom: {{customVar}}" }; + const context: PromptContext = { custom: { customVar: "test-value" } }; + const result = service.formatPrompt(personality, context); + expect(result.systemPrompt).toContain("test-value"); + }); + }); + + describe("buildSystemPrompt", () => { + it("should build complete prompt with context", () => { + const context: PromptContext = { userName: "Jane" }; + const result = service.buildSystemPrompt(mockPersonality, context); + expect(result).toContain("Jane"); + }); + + it("should include date/time when requested", () => { + const context: PromptContext = { currentDate: "2024-01-29", currentTime: "14:30", timezone: "UTC" }; + const result = service.buildSystemPrompt(mockPersonality, context, { includeDateTime: true }); + expect(result).toContain("2024-01-29"); + expect(result).toContain("14:30"); + }); + + it("should include additional instructions", () => { + const result = service.buildSystemPrompt(mockPersonality, undefined, { + additionalInstructions: "Be concise.", + }); + expect(result).toContain("Be concise."); + }); + }); + + describe("validateTemplate", () => { + it("should validate known variables", () => { + const template = "Hello {{userName}}, welcome to {{workspaceName}}!"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(true); + }); + + it("should report unknown variables", () => { + const template = "Hello {{unknownVar}}!"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(false); + expect(result.missingVariables).toContain("unknownVar"); + }); + + it("should allow custom_ prefixed variables", () => { + const template = "Value: {{custom_myVar}}"; + const result = service.validateTemplate(template); + expect(result.valid).toBe(true); + }); + }); + + describe("getFormalityLevels", () => { + it("should return all formality levels", () => { + const levels = service.getFormalityLevels(); + expect(levels).toHaveLength(5); + expect(levels.map((l) => l.level)).toContain("FORMAL"); + }); + }); +}); diff --git a/apps/api/src/personalities/services/prompt-formatter.service.ts b/apps/api/src/personalities/services/prompt-formatter.service.ts new file mode 100644 index 0000000..cf9bce4 --- /dev/null +++ b/apps/api/src/personalities/services/prompt-formatter.service.ts @@ -0,0 +1,137 @@ +import { Injectable } from "@nestjs/common"; +import { FormalityLevel } from "@prisma/client"; +import { Personality } from "../entities/personality.entity"; + +export interface PromptContext { + userName?: string; + workspaceName?: string; + currentDate?: string; + currentTime?: string; + timezone?: string; + custom?: Record; +} + +export interface FormattedPrompt { + systemPrompt: string; + metadata: { + personalityId: string; + personalityName: string; + tone: string; + formalityLevel: FormalityLevel; + formattedAt: Date; + }; +} + +const FORMALITY_MODIFIERS: Record = { + VERY_CASUAL: "Be extremely relaxed and friendly. Use casual language, contractions, and even emojis when appropriate.", + CASUAL: "Be friendly and approachable. Use conversational language and a warm tone.", + NEUTRAL: "Be professional yet approachable. Balance formality with friendliness.", + FORMAL: "Be professional and respectful. Use proper grammar and formal language.", + VERY_FORMAL: "Be highly professional and formal. Use precise language and maintain a respectful, business-like demeanor.", +}; + +@Injectable() +export class PromptFormatterService { + formatPrompt(personality: Personality, context?: PromptContext): FormattedPrompt { + let prompt = personality.systemPromptTemplate; + prompt = this.interpolateVariables(prompt, context); + + if (!prompt.toLowerCase().includes("formality") && !prompt.toLowerCase().includes(personality.formalityLevel.toLowerCase())) { + const modifier = FORMALITY_MODIFIERS[personality.formalityLevel]; + prompt = `${prompt}\n\n${modifier}`; + } + + if (!prompt.toLowerCase().includes(personality.tone.toLowerCase())) { + prompt = `${prompt}\n\nMaintain a ${personality.tone} tone throughout the conversation.`; + } + + return { + systemPrompt: prompt.trim(), + metadata: { + personalityId: personality.id, + personalityName: personality.name, + tone: personality.tone, + formalityLevel: personality.formalityLevel, + formattedAt: new Date(), + }, + }; + } + + buildSystemPrompt( + personality: Personality, + context?: PromptContext, + options?: { includeDateTime?: boolean; additionalInstructions?: string }, + ): string { + const { systemPrompt } = this.formatPrompt(personality, context); + const parts: string[] = [systemPrompt]; + + if (options?.includeDateTime === true) { + const now = new Date(); + const dateStr = context?.currentDate ?? now.toISOString().split("T")[0]; + const timeStr = context?.currentTime ?? now.toTimeString().slice(0, 5); + const tzStr = context?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + parts.push(`Current date: ${dateStr}, Time: ${timeStr} (${tzStr})`); + } + + if (options?.additionalInstructions !== undefined && options.additionalInstructions.length > 0) { + parts.push(options.additionalInstructions); + } + + return parts.join("\n\n"); + } + + private interpolateVariables(template: string, context?: PromptContext): string { + if (context === undefined) { + return template; + } + + let result = template; + + if (context.userName !== undefined) { + result = result.replace(/\{\{userName\}\}/g, context.userName); + } + if (context.workspaceName !== undefined) { + result = result.replace(/\{\{workspaceName\}\}/g, context.workspaceName); + } + if (context.currentDate !== undefined) { + result = result.replace(/\{\{currentDate\}\}/g, context.currentDate); + } + if (context.currentTime !== undefined) { + result = result.replace(/\{\{currentTime\}\}/g, context.currentTime); + } + if (context.timezone !== undefined) { + result = result.replace(/\{\{timezone\}\}/g, context.timezone); + } + + if (context.custom !== undefined) { + for (const [key, value] of Object.entries(context.custom)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g"); + result = result.replace(regex, value); + } + } + + return result; + } + + validateTemplate(template: string): { valid: boolean; missingVariables: string[] } { + const variablePattern = /\{\{(\w+)\}\}/g; + const matches = template.matchAll(variablePattern); + const variables = Array.from(matches, (m) => m[1]); + + const allowedVariables = new Set(["userName", "workspaceName", "currentDate", "currentTime", "timezone"]); + + const unknownVariables = variables.filter((v) => !allowedVariables.has(v) && !v.startsWith("custom_")); + + return { + valid: unknownVariables.length === 0, + missingVariables: unknownVariables, + }; + } + + getFormalityLevels(): Array<{ level: FormalityLevel; description: string }> { + return Object.entries(FORMALITY_MODIFIERS).map(([level, description]) => ({ + level: level as FormalityLevel, + description, + })); + } +} diff --git a/apps/api/src/websocket/websocket.gateway.ts b/apps/api/src/websocket/websocket.gateway.ts index 1ad17f6..fbc138f 100644 --- a/apps/api/src/websocket/websocket.gateway.ts +++ b/apps/api/src/websocket/websocket.gateway.ts @@ -135,6 +135,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec this.logger.debug(`Emitted event:deleted to ${room}`); } + /** + * Emit project:created event to workspace room + */ + emitProjectCreated(workspaceId: string, project: Project): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('project:created', project); + this.logger.debug(`Emitted project:created to ${room}`); + } + /** * Emit project:updated event to workspace room */ @@ -144,6 +153,15 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec this.logger.debug(`Emitted project:updated to ${room}`); } + /** + * Emit project:deleted event to workspace room + */ + emitProjectDeleted(workspaceId: string, projectId: string): void { + const room = this.getWorkspaceRoom(workspaceId); + this.server.to(room).emit('project:deleted', { id: projectId }); + this.logger.debug(`Emitted project:deleted to ${room}`); + } + /** * Get workspace room name */ diff --git a/apps/web/src/components/gantt/GanttChart.test.tsx b/apps/web/src/components/gantt/GanttChart.test.tsx index 27f2877..8e25088 100644 --- a/apps/web/src/components/gantt/GanttChart.test.tsx +++ b/apps/web/src/components/gantt/GanttChart.test.tsx @@ -91,7 +91,7 @@ describe("GanttChart", () => { render(); const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']"); - expect(taskRow).toHaveClass(/completed/i); + expect(taskRow?.className).toMatch(/Completed/i); }); it("should visually distinguish in-progress tasks", () => { @@ -106,7 +106,7 @@ describe("GanttChart", () => { render(); const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']"); - expect(taskRow).toHaveClass(/in-progress/i); + expect(taskRow?.className).toMatch(/InProgress/i); }); }); @@ -354,28 +354,36 @@ describe("GanttChart", () => { }); }); - describe("Dependencies (stretch goal)", () => { + describe("Dependencies", () => { it("should render dependency lines when showDependencies is true", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Foundation", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-10"), }), createGanttTask({ id: "task-2", title: "Build on top", + startDate: new Date("2026-02-11"), + endDate: new Date("2026-02-20"), dependencies: ["task-1"], }), ]; render(); - // Check if dependency visualization exists + // Check if dependency SVG exists const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); - // Specific dependency rendering will depend on implementation - // This is a basic check that the prop is accepted + // Look for dependency path element + const svg = chart.querySelector(".gantt-dependencies"); + expect(svg).toBeInTheDocument(); + + const paths = chart.querySelectorAll(".dependency-line"); + expect(paths).toHaveLength(1); }); it("should not render dependencies by default", () => { @@ -396,6 +404,138 @@ describe("GanttChart", () => { // Dependencies should not be shown by default const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); + + const svg = chart.querySelector(".gantt-dependencies"); + expect(svg).not.toBeInTheDocument(); + }); + + it("should handle tasks with non-existent dependencies gracefully", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Task 1", + dependencies: ["non-existent-task"], + }), + ]; + + render(); + + // Should not crash + const chart = screen.getByRole("region", { name: /gantt chart/i }); + expect(chart).toBeInTheDocument(); + }); + + it("should render multiple dependency lines", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Task 1", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-05"), + }), + createGanttTask({ + id: "task-2", + title: "Task 2", + startDate: new Date("2026-02-01"), + endDate: new Date("2026-02-05"), + }), + createGanttTask({ + id: "task-3", + title: "Task 3", + startDate: new Date("2026-02-06"), + endDate: new Date("2026-02-10"), + dependencies: ["task-1", "task-2"], + }), + ]; + + render(); + + const chart = screen.getByRole("region", { name: /gantt chart/i }); + const paths = chart.querySelectorAll(".dependency-line"); + expect(paths).toHaveLength(2); + }); + }); + + describe("Milestones", () => { + it("should render milestone as diamond shape", () => { + const milestone = createGanttTask({ + id: "milestone-1", + title: "Phase 1 Complete", + startDate: new Date("2026-02-15"), + endDate: new Date("2026-02-15"), + isMilestone: true, + }); + + render(); + + const milestoneElement = screen.getByRole("button", { + name: /milestone.*phase 1 complete/i, + }); + expect(milestoneElement).toBeInTheDocument(); + expect(milestoneElement).toHaveClass("gantt-milestone"); + }); + + it("should handle click on milestone", async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + + const milestone = createGanttTask({ + id: "milestone-1", + title: "Milestone Task", + isMilestone: true, + }); + + render(); + + const milestoneElement = screen.getByRole("button", { + name: /milestone.*milestone task/i, + }); + await user.click(milestoneElement); + + expect(onTaskClick).toHaveBeenCalledWith(milestone); + }); + + it("should support keyboard navigation for milestones", async () => { + const user = userEvent.setup(); + const onTaskClick = vi.fn(); + + const milestone = createGanttTask({ + id: "milestone-1", + title: "Keyboard Milestone", + isMilestone: true, + }); + + render(); + + const milestoneElement = screen.getByRole("button", { + name: /milestone/i, + }); + + await user.tab(); + expect(milestoneElement).toHaveFocus(); + + await user.keyboard("{Enter}"); + expect(onTaskClick).toHaveBeenCalled(); + }); + + it("should render milestones and regular tasks together", () => { + const tasks = [ + createGanttTask({ + id: "task-1", + title: "Regular Task", + isMilestone: false, + }), + createGanttTask({ + id: "milestone-1", + title: "Milestone", + isMilestone: true, + }), + ]; + + render(); + + expect(screen.getByRole("button", { name: /gantt bar.*regular task/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /milestone.*milestone/i })).toBeInTheDocument(); }); }); }); diff --git a/apps/web/src/components/gantt/GanttChart.tsx b/apps/web/src/components/gantt/GanttChart.tsx index 8190a2a..8539ba7 100644 --- a/apps/web/src/components/gantt/GanttChart.tsx +++ b/apps/web/src/components/gantt/GanttChart.tsx @@ -3,7 +3,20 @@ import type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types"; import { TaskStatus } from "@mosaic/shared"; import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format"; -import { useMemo } from "react"; +import { useMemo, useCallback } from "react"; +import styles from "./gantt.module.css"; + +/** + * Represents a dependency line between two tasks + */ +interface DependencyLine { + fromTaskId: string; + toTaskId: string; + fromX: number; + fromY: number; + toX: number; + toY: number; +} /** * Calculate the timeline range from a list of tasks @@ -99,11 +112,11 @@ function getStatusClass(status: TaskStatus): string { function getRowStatusClass(status: TaskStatus): string { switch (status) { case TaskStatus.COMPLETED: - return "gantt-row-completed"; + return styles.rowCompleted; case TaskStatus.IN_PROGRESS: - return "gantt-row-in-progress"; + return styles.rowInProgress; case TaskStatus.PAUSED: - return "gantt-row-paused"; + return styles.rowPaused; default: return ""; } @@ -135,6 +148,65 @@ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; po return labels; } +/** + * Calculate dependency lines between tasks + */ +function calculateDependencyLines( + tasks: GanttTask[], + timelineRange: TimelineRange +): DependencyLine[] { + const lines: DependencyLine[] = []; + const taskIndexMap = new Map(); + + // Build index map + tasks.forEach((task, index) => { + taskIndexMap.set(task.id, index); + }); + + const { start: rangeStart, totalDays } = timelineRange; + + tasks.forEach((task, toIndex) => { + if (!task.dependencies || task.dependencies.length === 0) { + return; + } + + task.dependencies.forEach((depId) => { + const fromIndex = taskIndexMap.get(depId); + if (fromIndex === undefined) { + return; + } + + const fromTask = tasks[fromIndex]; + + // Calculate positions (as percentages) + const fromEndOffset = Math.max( + 0, + (fromTask.endDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) + ); + const toStartOffset = Math.max( + 0, + (task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24) + ); + + const fromX = (fromEndOffset / totalDays) * 100; + const toX = (toStartOffset / totalDays) * 100; + const fromY = fromIndex * 48 + 24; // Center of the row + const toY = toIndex * 48 + 24; + + lines.push({ + fromTaskId: depId, + toTaskId: task.id, + fromX, + fromY, + toX, + toY, + }); + }); + }); + + return lines; +} + /** * Main Gantt Chart Component */ @@ -155,20 +227,32 @@ export function GanttChart({ // Generate timeline labels const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]); - const handleTaskClick = (task: GanttTask) => (): void => { - if (onTaskClick) { - onTaskClick(task); - } - }; + // Calculate dependency lines + const dependencyLines = useMemo( + () => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []), + [showDependencies, sortedTasks, timelineRange] + ); - const handleKeyDown = (task: GanttTask) => (e: React.KeyboardEvent): void => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); + const handleTaskClick = useCallback( + (task: GanttTask) => (): void => { if (onTaskClick) { onTaskClick(task); } - } - }; + }, + [onTaskClick] + ); + + const handleKeyDown = useCallback( + (task: GanttTask) => (e: React.KeyboardEvent): void => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + if (onTaskClick) { + onTaskClick(task); + } + } + }, + [onTaskClick] + ); return (
- {/* Task bars */} + {/* Dependency lines SVG */} + {showDependencies && dependencyLines.length > 0 && ( + + )} + + {/* Task bars and milestones */} {sortedTasks.map((task, index) => { const position = calculateBarPosition(task, timelineRange, index); const statusClass = getStatusClass(task.status); + // Render milestone as diamond shape + if (task.isMilestone === true) { + return ( +
+
+
+ ); + } + return (
- - {/* CSS for status classes */} -
); } diff --git a/apps/web/src/components/gantt/gantt.module.css b/apps/web/src/components/gantt/gantt.module.css new file mode 100644 index 0000000..a81d090 --- /dev/null +++ b/apps/web/src/components/gantt/gantt.module.css @@ -0,0 +1,12 @@ +/* Gantt Chart Status Row Styles */ +.rowCompleted { + background-color: #f0fdf4; /* green-50 */ +} + +.rowInProgress { + background-color: #eff6ff; /* blue-50 */ +} + +.rowPaused { + background-color: #fefce8; /* yellow-50 */ +} diff --git a/apps/web/src/components/gantt/index.test.ts b/apps/web/src/components/gantt/index.test.ts new file mode 100644 index 0000000..538986c --- /dev/null +++ b/apps/web/src/components/gantt/index.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { + GanttChart, + toGanttTask, + toGanttTasks, +} from "./index"; + +describe("Gantt module exports", () => { + it("should export GanttChart component", () => { + expect(GanttChart).toBeDefined(); + expect(typeof GanttChart).toBe("function"); + }); + + it("should export toGanttTask helper", () => { + expect(toGanttTask).toBeDefined(); + expect(typeof toGanttTask).toBe("function"); + }); + + it("should export toGanttTasks helper", () => { + expect(toGanttTasks).toBeDefined(); + expect(typeof toGanttTasks).toBe("function"); + }); +}); diff --git a/apps/web/src/components/gantt/index.ts b/apps/web/src/components/gantt/index.ts index 0775b57..6510951 100644 --- a/apps/web/src/components/gantt/index.ts +++ b/apps/web/src/components/gantt/index.ts @@ -1,7 +1,13 @@ /** * Gantt Chart component exports + * @module gantt */ export { GanttChart } from "./GanttChart"; -export type { GanttTask, GanttChartProps, TimelineRange, GanttBarPosition } from "./types"; +export type { + GanttTask, + GanttChartProps, + TimelineRange, + GanttBarPosition, +} from "./types"; export { toGanttTask, toGanttTasks } from "./types"; diff --git a/apps/web/src/components/gantt/types.test.ts b/apps/web/src/components/gantt/types.test.ts index 9aff77e..cd4f231 100644 --- a/apps/web/src/components/gantt/types.test.ts +++ b/apps/web/src/components/gantt/types.test.ts @@ -116,6 +116,50 @@ describe("Gantt Types Helpers", () => { expect(ganttTask?.dependencies).toBeUndefined(); }); + it("should extract isMilestone from metadata", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + isMilestone: true, + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.isMilestone).toBe(true); + }); + + it("should default isMilestone to false when not specified", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.isMilestone).toBe(false); + }); + + it("should handle non-boolean isMilestone in metadata", () => { + const task = createTask({ + metadata: { + startDate: "2026-02-01", + isMilestone: "yes", + }, + dueDate: new Date("2026-02-15"), + }); + + const ganttTask = toGanttTask(task); + + expect(ganttTask).not.toBeNull(); + expect(ganttTask?.isMilestone).toBe(false); + }); + it("should preserve all original task properties", () => { const task = createTask({ id: "special-task", diff --git a/apps/web/src/components/gantt/types.ts b/apps/web/src/components/gantt/types.ts index 06aa381..ef5ef3b 100644 --- a/apps/web/src/components/gantt/types.ts +++ b/apps/web/src/components/gantt/types.ts @@ -16,6 +16,8 @@ export interface GanttTask extends Task { endDate: Date; /** Optional array of task IDs that this task depends on */ dependencies?: string[]; + /** Whether this task is a milestone (zero-duration marker) */ + isMilestone?: boolean; } /** @@ -56,31 +58,50 @@ export interface GanttChartProps { showDependencies?: boolean; } +/** + * Type guard to check if a value is a valid date string + */ +function isDateString(value: unknown): value is string { + return typeof value === 'string' && !isNaN(Date.parse(value)); +} + +/** + * Type guard to check if a value is an array of strings + */ +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + /** * Helper to convert a base Task to GanttTask * Uses createdAt as startDate if not in metadata, dueDate as endDate */ export function toGanttTask(task: Task): GanttTask | null { // For Gantt chart, we need both start and end dates - const startDate = - (task.metadata?.startDate as string | undefined) - ? new Date(task.metadata.startDate as string) - : task.createdAt; - - const endDate = task.dueDate || new Date(); + const metadataStartDate = task.metadata?.startDate; + const startDate = isDateString(metadataStartDate) + ? new Date(metadataStartDate) + : task.createdAt; + + const endDate = task.dueDate ?? new Date(); // Validate dates if (!startDate || !endDate) { return null; } + // Extract dependencies with type guard + const metadataDependencies = task.metadata?.dependencies; + const dependencies = isStringArray(metadataDependencies) + ? metadataDependencies + : undefined; + return { ...task, startDate, endDate, - dependencies: Array.isArray(task.metadata?.dependencies) - ? (task.metadata.dependencies as string[]) - : undefined, + dependencies, + isMilestone: task.metadata?.isMilestone === true, }; } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6df952a..4afa758 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -131,3 +131,6 @@ export * from "./auth.types"; // Export widget types export * from "./widget.types"; + +// Export WebSocket types +export * from "./websocket.types"; diff --git a/packages/shared/src/types/websocket.types.ts b/packages/shared/src/types/websocket.types.ts new file mode 100644 index 0000000..0662eb5 --- /dev/null +++ b/packages/shared/src/types/websocket.types.ts @@ -0,0 +1,87 @@ +/** + * WebSocket event types for real-time updates + */ + +/** + * All supported WebSocket event names + */ +export enum WebSocketEvent { + // Task events + TASK_CREATED = 'task:created', + TASK_UPDATED = 'task:updated', + TASK_DELETED = 'task:deleted', + + // Event events + EVENT_CREATED = 'event:created', + EVENT_UPDATED = 'event:updated', + EVENT_DELETED = 'event:deleted', + + // Project events + PROJECT_CREATED = 'project:created', + PROJECT_UPDATED = 'project:updated', + PROJECT_DELETED = 'project:deleted', +} + +/** + * Base payload interface for all WebSocket events + */ +export interface BaseEventPayload { + id: string; + workspaceId: string; +} + +/** + * Task payload for WebSocket events + */ +export interface TaskEventPayload extends BaseEventPayload { + title?: string; + status?: string; + priority?: string; + assigneeId?: string | null; + projectId?: string | null; + dueDate?: string | null; + [key: string]: unknown; +} + +/** + * Event (calendar) payload for WebSocket events + */ +export interface CalendarEventPayload extends BaseEventPayload { + title?: string; + startTime?: string; + endTime?: string; + allDay?: boolean; + [key: string]: unknown; +} + +/** + * Project payload for WebSocket events + */ +export interface ProjectEventPayload extends BaseEventPayload { + name?: string; + status?: string; + color?: string; + [key: string]: unknown; +} + +/** + * Delete payload - only includes the entity ID + */ +export interface DeleteEventPayload { + id: string; +} + +/** + * WebSocket event handler callbacks + */ +export interface WebSocketCallbacks { + onTaskCreated?: (task: TaskEventPayload) => void; + onTaskUpdated?: (task: TaskEventPayload) => void; + onTaskDeleted?: (payload: DeleteEventPayload) => void; + onEventCreated?: (event: CalendarEventPayload) => void; + onEventUpdated?: (event: CalendarEventPayload) => void; + onEventDeleted?: (payload: DeleteEventPayload) => void; + onProjectCreated?: (project: ProjectEventPayload) => void; + onProjectUpdated?: (project: ProjectEventPayload) => void; + onProjectDeleted?: (payload: DeleteEventPayload) => void; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3553c9e..c3df81d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,15 @@ importers: apps/web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) '@mosaic/shared': specifier: workspace:* version: link:../../packages/shared @@ -586,6 +595,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -4760,6 +4791,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1