diff --git a/.devlog/devlog.sqlite b/.devlog/devlog.sqlite new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/[documentId]/route.ts b/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/[documentId]/route.ts new file mode 100644 index 00000000..f0ce8381 --- /dev/null +++ b/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/[documentId]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest } from 'next/server'; +import { DocumentService, DevlogService } from '@codervisor/devlog-core/server'; +import { ApiErrors, createSuccessResponse, RouteParams, ServiceHelper } from '@/lib/api/api-utils'; +import { RealtimeEventType } from '@/lib/realtime'; + +// Mark this route as dynamic to prevent static generation +export const dynamic = 'force-dynamic'; + +// GET /api/projects/[name]/devlogs/[devlogId]/documents/[documentId] - Get specific document +export async function GET( + request: NextRequest, + { params }: { params: { name: string; devlogId: string; documentId: string } }, +) { + try { + // Parse and validate parameters + const projectResult = RouteParams.parseProjectName(params); + if (!projectResult.success) { + return projectResult.response; + } + + const { projectName } = projectResult.data; + const { devlogId, documentId } = params; + + if (!devlogId || !documentId) { + return ApiErrors.invalidRequest('Missing devlogId or documentId'); + } + + // Parse devlogId as number + const parsedDevlogId = parseInt(devlogId); + if (isNaN(parsedDevlogId)) { + return ApiErrors.invalidRequest('Invalid devlogId'); + } + + // Get project using helper + const projectHelperResult = await ServiceHelper.getProjectByNameOrFail(projectName); + if (!projectHelperResult.success) { + return projectHelperResult.response; + } + + const project = projectHelperResult.data.project; + + // Verify devlog exists + const devlogService = DevlogService.getInstance(project.id); + const devlog = await devlogService.get(parsedDevlogId, false); + if (!devlog) { + return ApiErrors.devlogNotFound(); + } + + // Get document + const documentService = DocumentService.getInstance(project.id); + const document = await documentService.getDocument(documentId); + + if (!document) { + return ApiErrors.notFound('Document not found'); + } + + // Verify document belongs to the specified devlog + if (document.devlogId !== parsedDevlogId) { + return ApiErrors.notFound('Document not found'); + } + + return createSuccessResponse(document); + } catch (error) { + console.error('Error fetching document:', error); + return ApiErrors.internalError('Failed to fetch document'); + } +} + +// DELETE /api/projects/[name]/devlogs/[devlogId]/documents/[documentId] - Delete document +export async function DELETE( + request: NextRequest, + { params }: { params: { name: string; devlogId: string; documentId: string } }, +) { + try { + // Parse and validate parameters + const projectResult = RouteParams.parseProjectName(params); + if (!projectResult.success) { + return projectResult.response; + } + + const { projectName } = projectResult.data; + const { devlogId, documentId } = params; + + if (!devlogId || !documentId) { + return ApiErrors.invalidRequest('Missing devlogId or documentId'); + } + + // Parse devlogId as number + const parsedDevlogId = parseInt(devlogId); + if (isNaN(parsedDevlogId)) { + return ApiErrors.invalidRequest('Invalid devlogId'); + } + + // Get project using helper + const projectHelperResult = await ServiceHelper.getProjectByNameOrFail(projectName); + if (!projectHelperResult.success) { + return projectHelperResult.response; + } + + const project = projectHelperResult.data.project; + + // Verify devlog exists + const devlogService = DevlogService.getInstance(project.id); + const devlog = await devlogService.get(parsedDevlogId, false); + if (!devlog) { + return ApiErrors.devlogNotFound(); + } + + // Verify document exists and belongs to the devlog + const documentService = DocumentService.getInstance(project.id); + const document = await documentService.getDocument(documentId); + + if (!document || document.devlogId !== parsedDevlogId) { + return ApiErrors.notFound('Document not found'); + } + + // Delete document + const deleted = await documentService.deleteDocument(documentId); + + if (!deleted) { + return ApiErrors.internalError('Failed to delete document'); + } + + return createSuccessResponse( + { message: 'Document deleted successfully' }, + { + sseEventType: RealtimeEventType.DEVLOG_UPDATED, + } + ); + } catch (error) { + console.error('Error deleting document:', error); + return ApiErrors.internalError('Failed to delete document'); + } +} \ No newline at end of file diff --git a/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/route.ts b/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/route.ts new file mode 100644 index 00000000..679acb8b --- /dev/null +++ b/apps/web/app/api/projects/[name]/devlogs/[devlogId]/documents/route.ts @@ -0,0 +1,131 @@ +import { NextRequest } from 'next/server'; +import { DocumentService, DevlogService } from '@codervisor/devlog-core/server'; +import { ApiErrors, createSuccessResponse, RouteParams, ServiceHelper, createSimpleCollectionResponse } from '@/lib/api/api-utils'; +import { RealtimeEventType } from '@/lib/realtime'; + +// Mark this route as dynamic to prevent static generation +export const dynamic = 'force-dynamic'; + +// GET /api/projects/[name]/devlogs/[devlogId]/documents - List documents for a devlog +export async function GET( + request: NextRequest, + { params }: { params: { name: string; devlogId: string } }, +) { + try { + // Parse and validate parameters + const paramResult = RouteParams.parseProjectNameAndDevlogId(params); + if (!paramResult.success) { + return paramResult.response; + } + + const { projectName, devlogId } = paramResult.data; + + // Get project using helper + const projectResult = await ServiceHelper.getProjectByNameOrFail(projectName); + if (!projectResult.success) { + return projectResult.response; + } + + const project = projectResult.data.project; + + // Verify devlog exists + const devlogService = DevlogService.getInstance(project.id); + const devlog = await devlogService.get(devlogId, false); + if (!devlog) { + return ApiErrors.devlogNotFound(); + } + + // Get documents using document service + const documentService = DocumentService.getInstance(project.id); + const documents = await documentService.listDocuments(devlogId); + + return createSimpleCollectionResponse(documents); + } catch (error) { + console.error('Error fetching devlog documents:', error); + return ApiErrors.internalError('Failed to fetch documents'); + } +} + +// POST /api/projects/[name]/devlogs/[devlogId]/documents - Upload a document +export async function POST( + request: NextRequest, + { params }: { params: { name: string; devlogId: string } }, +) { + try { + // Parse and validate parameters + const paramResult = RouteParams.parseProjectNameAndDevlogId(params); + if (!paramResult.success) { + return paramResult.response; + } + + const { projectName, devlogId } = paramResult.data; + + // Get project using helper + const projectResult = await ServiceHelper.getProjectByNameOrFail(projectName); + if (!projectResult.success) { + return projectResult.response; + } + + const project = projectResult.data.project; + + // Verify devlog exists + const devlogService = DevlogService.getInstance(project.id); + const devlog = await devlogService.get(devlogId, false); + if (!devlog) { + return ApiErrors.devlogNotFound(); + } + + // Parse multipart form data + const formData = await request.formData(); + const file = formData.get('file') as File; + const metadata = formData.get('metadata') as string; + + if (!file) { + return ApiErrors.invalidRequest('File is required'); + } + + // Validate file size (10MB limit) + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + return ApiErrors.invalidRequest('File size exceeds 10MB limit'); + } + + // Read file content + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Parse metadata if provided + let parsedMetadata: Record | undefined; + if (metadata) { + try { + parsedMetadata = JSON.parse(metadata); + } catch { + return ApiErrors.invalidRequest('Invalid metadata JSON'); + } + } + + // Upload document + const documentService = DocumentService.getInstance(project.id); + const document = await documentService.uploadDocument( + devlogId, + { + originalName: file.name, + mimeType: file.type, + size: file.size, + content: buffer, + }, + { + metadata: parsedMetadata, + // TODO: Add uploadedBy from authentication context + } + ); + + return createSuccessResponse(document, { + status: 201, + sseEventType: RealtimeEventType.DEVLOG_UPDATED, + }); + } catch (error) { + console.error('Error uploading document:', error); + return ApiErrors.internalError('Failed to upload document'); + } +} \ No newline at end of file diff --git a/packages/core/src/entities/devlog-document.entity.ts b/packages/core/src/entities/devlog-document.entity.ts new file mode 100644 index 00000000..a428fffa --- /dev/null +++ b/packages/core/src/entities/devlog-document.entity.ts @@ -0,0 +1,136 @@ +/** + * DevlogDocument entity - separate table for devlog document attachments + * Stores file metadata and content for documents associated with devlog entries + */ + +import 'reflect-metadata'; +import { Column, Entity, Index, ManyToOne, JoinColumn, PrimaryColumn, CreateDateColumn } from 'typeorm'; +import type { DocumentType } from '../types/index.js'; +import { DevlogEntryEntity } from './devlog-entry.entity.js'; +import { JsonColumn, getTimestampType } from './decorators.js'; + +@Entity('devlog_documents') +@Index(['devlogId']) +@Index(['uploadedAt']) +@Index(['type']) +@Index(['mimeType']) +export class DevlogDocumentEntity { + @PrimaryColumn({ type: 'varchar', length: 255 }) + id!: string; + + @Column({ type: 'integer', name: 'devlog_id' }) + devlogId!: number; + + @Column({ type: 'varchar', length: 255 }) + filename!: string; + + @Column({ type: 'varchar', length: 255, name: 'original_name' }) + originalName!: string; + + @Column({ type: 'varchar', length: 255, name: 'mime_type' }) + mimeType!: string; + + @Column({ type: 'integer' }) + size!: number; + + @Column({ + type: 'varchar', + length: 50, + enum: ['text', 'markdown', 'image', 'pdf', 'code', 'json', 'csv', 'log', 'config', 'other'], + }) + type!: DocumentType; + + @Column({ type: 'text', nullable: true }) + content?: string; + + @JsonColumn({ nullable: true }) + metadata?: string; // Stored as JSON string, parsed in toDevlogDocument() + + @CreateDateColumn({ + type: getTimestampType(), + name: 'uploaded_at', + }) + uploadedAt!: Date; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'uploaded_by' }) + uploadedBy?: string; + + // Foreign key relationship + @ManyToOne(() => DevlogEntryEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'devlog_id' }) + devlogEntry!: DevlogEntryEntity; + + /** + * Convert entity to DevlogDocument interface + */ + toDevlogDocument(): import('../types/index.js').DevlogDocument { + return { + id: this.id, + devlogId: this.devlogId, + filename: this.filename, + originalName: this.originalName, + mimeType: this.mimeType, + size: this.size, + type: this.type, + content: this.content, + metadata: this.parseJsonField(this.metadata, {}), + uploadedAt: this.uploadedAt.toISOString(), + uploadedBy: this.uploadedBy, + }; + } + + /** + * Create entity from DevlogDocument interface + */ + static fromDevlogDocument(document: import('../types/index.js').DevlogDocument): DevlogDocumentEntity { + const entity = new DevlogDocumentEntity(); + + entity.id = document.id; + entity.devlogId = document.devlogId; + entity.filename = document.filename; + entity.originalName = document.originalName; + entity.mimeType = document.mimeType; + entity.size = document.size; + entity.type = document.type; + entity.content = document.content; + entity.metadata = entity.stringifyJsonField(document.metadata || {}); + entity.uploadedAt = new Date(document.uploadedAt); + entity.uploadedBy = document.uploadedBy; + + return entity; + } + + /** + * Helper method for JSON field parsing (database-specific) + */ + public parseJsonField(value: any, defaultValue: T): T { + if (value === null || value === undefined) { + return defaultValue; + } + + if (typeof value === 'string') { + try { + return JSON.parse(value); + } catch { + return defaultValue; + } + } + + return value; + } + + /** + * Helper method for JSON field stringification (database-specific) + */ + public stringifyJsonField(value: any): any { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + return JSON.stringify(value); + } +} \ No newline at end of file diff --git a/packages/core/src/entities/index.ts b/packages/core/src/entities/index.ts index 133e4977..66356a4e 100644 --- a/packages/core/src/entities/index.ts +++ b/packages/core/src/entities/index.ts @@ -1,6 +1,7 @@ export * from './devlog-entry.entity.js'; export * from './devlog-note.entity.js'; export * from './devlog-dependency.entity.js'; +export * from './devlog-document.entity.js'; export * from './project.entity.js'; export * from './chat-session.entity.js'; export * from './chat-message.entity.js'; diff --git a/packages/core/src/services/__tests__/document-service.test.ts b/packages/core/src/services/__tests__/document-service.test.ts new file mode 100644 index 00000000..fc9b5d66 --- /dev/null +++ b/packages/core/src/services/__tests__/document-service.test.ts @@ -0,0 +1,103 @@ +/** + * Document service tests + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { DocumentService } from '../document-service.js'; +import type { DevlogDocument } from '../../types/index.js'; + +// Mock data for testing +const mockFile = { + originalName: 'test-document.txt', + mimeType: 'text/plain', + size: 1024, + content: Buffer.from('This is a test document content', 'utf-8'), +}; + +const mockDevlogId = 1; + +describe('DocumentService', () => { + // Note: Database tests are skipped due to enum column compatibility issues with SQLite + // These tests focus on the business logic and type detection functionality + + describe('Document Type Detection', () => { + it('should detect text documents correctly', () => { + const service = DocumentService.getInstance(); + + // Access private method through any to test it + const detectType = (service as any).determineDocumentType.bind(service); + + expect(detectType('text/plain', '.txt')).toBe('text'); + expect(detectType('text/markdown', '.md')).toBe('markdown'); + expect(detectType('application/json', '.json')).toBe('json'); + expect(detectType('text/csv', '.csv')).toBe('csv'); + }); + + it('should detect code documents correctly', () => { + const service = DocumentService.getInstance(); + const detectType = (service as any).determineDocumentType.bind(service); + + expect(detectType('text/plain', '.js')).toBe('code'); + expect(detectType('text/plain', '.ts')).toBe('code'); + expect(detectType('text/plain', '.py')).toBe('code'); + expect(detectType('text/plain', '.java')).toBe('code'); + }); + + it('should detect images correctly', () => { + const service = DocumentService.getInstance(); + const detectType = (service as any).determineDocumentType.bind(service); + + expect(detectType('image/png', '.png')).toBe('image'); + expect(detectType('image/jpeg', '.jpg')).toBe('image'); + expect(detectType('image/gif', '.gif')).toBe('image'); + }); + + it('should detect PDFs correctly', () => { + const service = DocumentService.getInstance(); + const detectType = (service as any).determineDocumentType.bind(service); + + expect(detectType('application/pdf', '.pdf')).toBe('pdf'); + }); + + it('should default to other for unknown types', () => { + const service = DocumentService.getInstance(); + const detectType = (service as any).determineDocumentType.bind(service); + + expect(detectType('application/unknown', '.xyz')).toBe('other'); + }); + }); + + describe('Text Content Extraction', () => { + it('should identify text-based types correctly', () => { + const service = DocumentService.getInstance(); + const isTextBased = (service as any).isTextBasedType.bind(service); + + expect(isTextBased('text')).toBe(true); + expect(isTextBased('markdown')).toBe(true); + expect(isTextBased('code')).toBe(true); + expect(isTextBased('json')).toBe(true); + expect(isTextBased('csv')).toBe(true); + expect(isTextBased('log')).toBe(true); + expect(isTextBased('config')).toBe(true); + + expect(isTextBased('image')).toBe(false); + expect(isTextBased('pdf')).toBe(false); + expect(isTextBased('other')).toBe(false); + }); + + it('should extract text content from strings and buffers', () => { + const service = DocumentService.getInstance(); + const extractText = (service as any).extractTextContent.bind(service); + + const textContent = 'Hello, World!'; + const bufferContent = Buffer.from(textContent, 'utf-8'); + + expect(extractText(textContent, 'text')).toBe(textContent); + expect(extractText(bufferContent, 'text')).toBe(textContent); + expect(extractText(bufferContent, 'image')).toBe(''); + }); + }); + + // Note: More comprehensive integration tests would require a test database + // These tests focus on the business logic and type detection functionality +}); \ No newline at end of file diff --git a/packages/core/src/services/devlog-service.ts b/packages/core/src/services/devlog-service.ts index c82a00bc..346adcad 100644 --- a/packages/core/src/services/devlog-service.ts +++ b/packages/core/src/services/devlog-service.ts @@ -23,7 +23,7 @@ import type { TimeSeriesRequest, TimeSeriesStats, } from '../types/index.js'; -import { DevlogEntryEntity, DevlogNoteEntity } from '../entities/index.js'; +import { DevlogEntryEntity, DevlogNoteEntity, DevlogDocumentEntity } from '../entities/index.js'; import { getDataSource } from '../utils/typeorm-config.js'; import { getStorageType } from '../entities/decorators.js'; import { DevlogValidator } from '../validation/devlog-schemas.js'; @@ -40,6 +40,7 @@ export class DevlogService { private database: DataSource; private devlogRepository: Repository; private noteRepository: Repository; + private documentRepository: Repository; private pgTrgmAvailable: boolean = false; private initPromise: Promise | null = null; @@ -48,6 +49,7 @@ export class DevlogService { this.database = null as any; // Temporary placeholder this.devlogRepository = null as any; // Temporary placeholder this.noteRepository = null as any; // Temporary placeholder + this.documentRepository = null as any; // Temporary placeholder } /** @@ -72,6 +74,7 @@ export class DevlogService { this.database = await getDataSource(); this.devlogRepository = this.database.getRepository(DevlogEntryEntity); this.noteRepository = this.database.getRepository(DevlogNoteEntity); + this.documentRepository = this.database.getRepository(DevlogDocumentEntity); console.log( '[DevlogService] DataSource ready with entities:', this.database.entityMetadatas.length, @@ -146,7 +149,7 @@ export class DevlogService { return existingInstance.service; } - async get(id: DevlogId, includeNotes = true): Promise { + async get(id: DevlogId, includeNotes = true, includeDocuments = false): Promise { await this.ensureInitialized(); // Validate devlog ID @@ -168,6 +171,11 @@ export class DevlogService { devlogEntry.notes = await this.getNotes(id); } + // Load documents if requested + if (includeDocuments) { + devlogEntry.documents = await this.getDocuments(id); + } + return devlogEntry; } @@ -205,6 +213,35 @@ export class DevlogService { })); } + /** + * Get documents for a specific devlog entry + */ + async getDocuments( + devlogId: DevlogId, + limit?: number, + ): Promise { + await this.ensureInitialized(); + + // Validate devlog ID + const idValidation = DevlogValidator.validateDevlogId(devlogId); + if (!idValidation.success) { + throw new Error(`Invalid devlog ID: ${idValidation.errors.join(', ')}`); + } + + const queryBuilder = this.documentRepository + .createQueryBuilder('document') + .where('document.devlogId = :devlogId', { devlogId: idValidation.data }) + .orderBy('document.uploadedAt', 'DESC'); + + if (limit && limit > 0) { + queryBuilder.limit(limit); + } + + const documentEntities = await queryBuilder.getMany(); + + return documentEntities.map((entity) => entity.toDevlogDocument()); + } + /** * Add a note to a devlog entry */ diff --git a/packages/core/src/services/document-service.ts b/packages/core/src/services/document-service.ts new file mode 100644 index 00000000..96cc0f9d --- /dev/null +++ b/packages/core/src/services/document-service.ts @@ -0,0 +1,352 @@ +/** + * DocumentService - Business logic for devlog document operations + * + * Handles CRUD operations for documents associated with devlog entries, + * including file uploads, metadata management, and content indexing. + */ + +import { DataSource, Repository } from 'typeorm'; +import type { DevlogDocument, DevlogId } from '../types/index.js'; +import { DevlogDocumentEntity, DevlogEntryEntity } from '../entities/index.js'; +import { getDataSource } from '../utils/typeorm-config.js'; +import { generateDocumentId } from '../utils/id-generator.js'; +import * as crypto from 'crypto'; +import * as path from 'path'; + +interface DocumentServiceInstance { + service: DocumentService; + createdAt: number; +} + +export class DocumentService { + private static instances: Map = new Map(); + private static readonly TTL_MS = 5 * 60 * 1000; // 5 minutes TTL + private database: DataSource; + private documentRepository: Repository; + private devlogRepository: Repository; + private initPromise: Promise | null = null; + + private constructor(private projectId?: number) { + // Database initialization will happen in ensureInitialized() + this.database = null as any; // Temporary placeholder + this.documentRepository = null as any; // Temporary placeholder + this.devlogRepository = null as any; // Temporary placeholder + } + + /** + * Get singleton instance for a project + */ + static getInstance(projectId?: number): DocumentService { + const key = projectId || 0; + const now = Date.now(); + + // Clean up expired instances + for (const [instanceKey, instance] of this.instances.entries()) { + if (now - instance.createdAt > this.TTL_MS) { + this.instances.delete(instanceKey); + } + } + + let instance = this.instances.get(key); + if (!instance) { + instance = { + service: new DocumentService(projectId), + createdAt: now, + }; + this.instances.set(key, instance); + } + + return instance.service; + } + + /** + * Ensure service is initialized + */ + async ensureInitialized(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = this._initialize(); + return this.initPromise; + } + + private async _initialize(): Promise { + this.database = await getDataSource(); + this.documentRepository = this.database.getRepository(DevlogDocumentEntity); + this.devlogRepository = this.database.getRepository(DevlogEntryEntity); + } + + /** + * Upload a document and associate it with a devlog entry + */ + async uploadDocument( + devlogId: DevlogId, + file: { + originalName: string; + mimeType: string; + size: number; + content?: Buffer | string; + }, + options?: { + uploadedBy?: string; + metadata?: Record; + } + ): Promise { + await this.ensureInitialized(); + + // Verify devlog exists + const devlogExists = await this.devlogRepository.findOne({ + where: { id: devlogId, ...(this.projectId && { projectId: this.projectId }) }, + }); + + if (!devlogExists) { + throw new Error(`Devlog entry ${devlogId} not found`); + } + + // Generate unique document ID and filename + const documentId = generateDocumentId(devlogId, file.originalName); + const extension = path.extname(file.originalName); + const filename = `${documentId}${extension}`; + + // Determine document type from mime type and extension + const type = this.determineDocumentType(file.mimeType, extension); + + // Extract text content for searchable documents + let textContent: string | undefined; + if (file.content && this.isTextBasedType(type)) { + textContent = this.extractTextContent(file.content, type); + } + + // Create document entity + const document: DevlogDocument = { + id: documentId, + devlogId, + filename, + originalName: file.originalName, + mimeType: file.mimeType, + size: file.size, + type, + content: textContent, + metadata: options?.metadata, + uploadedAt: new Date().toISOString(), + uploadedBy: options?.uploadedBy, + }; + + const entity = DevlogDocumentEntity.fromDevlogDocument(document); + const savedEntity = await this.documentRepository.save(entity); + + return savedEntity.toDevlogDocument(); + } + + /** + * Get a specific document by ID + */ + async getDocument(documentId: string): Promise { + await this.ensureInitialized(); + + const entity = await this.documentRepository.findOne({ + where: { id: documentId }, + relations: ['devlogEntry'], + }); + + if (!entity) { + return null; + } + + // Check project access if projectId is set + if (this.projectId && entity.devlogEntry.projectId !== this.projectId) { + return null; + } + + return entity.toDevlogDocument(); + } + + /** + * List documents for a devlog entry + */ + async listDocuments(devlogId: DevlogId): Promise { + await this.ensureInitialized(); + + const entities = await this.documentRepository.find({ + where: { devlogId }, + order: { uploadedAt: 'DESC' }, + relations: ['devlogEntry'], + }); + + // Filter by project if projectId is set + const filteredEntities = this.projectId + ? entities.filter(entity => entity.devlogEntry.projectId === this.projectId) + : entities; + + return filteredEntities.map(entity => entity.toDevlogDocument()); + } + + /** + * Delete a document + */ + async deleteDocument(documentId: string): Promise { + await this.ensureInitialized(); + + const entity = await this.documentRepository.findOne({ + where: { id: documentId }, + relations: ['devlogEntry'], + }); + + if (!entity) { + return false; + } + + // Check project access if projectId is set + if (this.projectId && entity.devlogEntry.projectId !== this.projectId) { + return false; + } + + await this.documentRepository.remove(entity); + return true; + } + + /** + * Update document metadata + */ + async updateDocument( + documentId: string, + updates: { + metadata?: Record; + content?: string; + } + ): Promise { + await this.ensureInitialized(); + + const entity = await this.documentRepository.findOne({ + where: { id: documentId }, + relations: ['devlogEntry'], + }); + + if (!entity) { + return null; + } + + // Check project access if projectId is set + if (this.projectId && entity.devlogEntry.projectId !== this.projectId) { + return null; + } + + if (updates.metadata !== undefined) { + entity.metadata = entity.stringifyJsonField(updates.metadata); + } + + if (updates.content !== undefined) { + entity.content = updates.content; + } + + const savedEntity = await this.documentRepository.save(entity); + return savedEntity.toDevlogDocument(); + } + + /** + * Search documents by content + */ + async searchDocuments( + query: string, + devlogId?: DevlogId + ): Promise { + await this.ensureInitialized(); + + let queryBuilder = this.documentRepository + .createQueryBuilder('doc') + .leftJoinAndSelect('doc.devlogEntry', 'devlog'); + + // Add project filter if projectId is set + if (this.projectId) { + queryBuilder = queryBuilder.where('devlog.projectId = :projectId', { projectId: this.projectId }); + } + + // Add devlog filter if specified + if (devlogId) { + queryBuilder = queryBuilder.andWhere('doc.devlogId = :devlogId', { devlogId }); + } + + // Add content search + queryBuilder = queryBuilder.andWhere( + '(doc.content ILIKE :query OR doc.originalName ILIKE :query OR doc.filename ILIKE :query)', + { query: `%${query}%` } + ); + + queryBuilder = queryBuilder.orderBy('doc.uploadedAt', 'DESC'); + + const entities = await queryBuilder.getMany(); + return entities.map(entity => entity.toDevlogDocument()); + } + + /** + * Determine document type from MIME type and file extension + */ + private determineDocumentType(mimeType: string, extension: string): import('../types/index.js').DocumentType { + // Image types + if (mimeType.startsWith('image/')) { + return 'image'; + } + + // PDF + if (mimeType === 'application/pdf') { + return 'pdf'; + } + + // JSON (check before text types) + if (mimeType === 'application/json' || extension === '.json') { + return 'json'; + } + + // Code files (check before general text types) + const codeExtensions = ['.js', '.ts', '.py', '.java', '.cpp', '.c', '.go', '.rs', '.php', '.rb', '.swift', '.kt']; + if (codeExtensions.includes(extension.toLowerCase())) { + return 'code'; + } + + // Config files (check before general text types) + const configExtensions = ['.env', '.conf', '.ini', '.yaml', '.yml', '.toml', '.properties']; + if (configExtensions.includes(extension.toLowerCase())) { + return 'config'; + } + + // Text-based types (more specific checks first) + if (mimeType.startsWith('text/')) { + if (mimeType === 'text/markdown' || extension === '.md') { + return 'markdown'; + } + if (extension === '.csv') { + return 'csv'; + } + if (extension === '.log') { + return 'log'; + } + return 'text'; + } + + return 'other'; + } + + /** + * Check if document type supports text content extraction + */ + private isTextBasedType(type: import('../types/index.js').DocumentType): boolean { + return ['text', 'markdown', 'code', 'json', 'csv', 'log', 'config'].includes(type); + } + + /** + * Extract text content from file content + */ + private extractTextContent(content: Buffer | string, type: import('../types/index.js').DocumentType): string { + if (typeof content === 'string') { + return content; + } + + // For text-based files, convert buffer to string + if (this.isTextBasedType(type)) { + return content.toString('utf-8'); + } + + return ''; + } +} \ No newline at end of file diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index cf99b610..ef5d7f14 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,5 +1,6 @@ export { DevlogService } from './devlog-service.js'; export { ProjectService } from './project-service.js'; +export { DocumentService } from './document-service.js'; export { LLMService, createLLMServiceFromEnv, getLLMService } from './llm-service.js'; export type { LLMServiceConfig } from './llm-service.js'; // export { AuthService } from './auth-service.js'; // Moved to auth.ts export diff --git a/packages/core/src/types/core.ts b/packages/core/src/types/core.ts index 82417732..15b5e38f 100644 --- a/packages/core/src/types/core.ts +++ b/packages/core/src/types/core.ts @@ -163,6 +163,38 @@ export interface DevlogNote { content: string; } +/** + * Document types supported by the devlog system + */ +export type DocumentType = + | 'text' // Plain text files + | 'markdown' // Markdown files + | 'image' // Images (png, jpg, gif, etc.) + | 'pdf' // PDF documents + | 'code' // Source code files + | 'json' // JSON data files + | 'csv' // CSV data files + | 'log' // Log files + | 'config' // Configuration files + | 'other'; // Other file types + +/** + * Document interface for files attached to devlog entries + */ +export interface DevlogDocument { + id: string; + devlogId: number; + filename: string; + originalName: string; + mimeType: string; + size: number; // Size in bytes + type: DocumentType; + content?: string; // Text content for searchable documents + metadata?: Record; // Additional file metadata + uploadedAt: string; // ISO timestamp + uploadedBy?: string; // User who uploaded the document +} + export interface DevlogEntry { id?: DevlogId; key?: string; // Semantic key (e.g., "web-ui-issues-investigation") @@ -186,6 +218,7 @@ export interface DevlogEntry { // Related entities (loaded separately, not stored as JSON) notes?: DevlogNote[]; dependencies?: Dependency[]; + documents?: DevlogDocument[]; } export interface Dependency { diff --git a/packages/core/src/utils/id-generator.ts b/packages/core/src/utils/id-generator.ts new file mode 100644 index 00000000..fa2f126d --- /dev/null +++ b/packages/core/src/utils/id-generator.ts @@ -0,0 +1,49 @@ +/** + * ID generation utilities for various entities + */ + +import { createHash, randomBytes } from 'crypto'; + +/** + * Generate a unique ID using crypto random bytes and timestamp + * + * @param prefix - Optional prefix for the ID + * @returns A unique string ID + */ +export function generateUniqueId(prefix?: string): string { + const timestamp = Date.now().toString(36); + const randomPart = randomBytes(8).toString('hex'); + + if (prefix) { + return `${prefix}-${timestamp}-${randomPart}`; + } + + return `${timestamp}-${randomPart}`; +} + +/** + * Generate a hash-based ID from input data + * + * @param input - Input data to hash + * @param length - Length of the resulting hash (default: 16) + * @returns A hash-based ID + */ +export function generateHashId(input: string, length: number = 16): string { + return createHash('sha256') + .update(input) + .digest('hex') + .substring(0, length); +} + +/** + * Generate a document-specific ID with timestamp and random component + * + * @param devlogId - The devlog ID this document belongs to + * @param originalName - The original filename + * @returns A unique document ID + */ +export function generateDocumentId(devlogId: number, originalName: string): string { + const input = `${devlogId}-${originalName}-${Date.now()}`; + const hash = generateHashId(input, 12); + return `doc-${hash}`; +} \ No newline at end of file diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 88495fa6..f8be0969 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -9,6 +9,7 @@ export * from './env-loader.js'; export * from './field-change-tracking.js'; export * from './change-history.js'; export * from './key-generator.js'; +export * from './id-generator.js'; export * from './project-name.js'; // NOTE: typeorm-config.ts is NOT exported here to prevent client-side import issues diff --git a/packages/mcp/src/adapters/mcp-adapter.ts b/packages/mcp/src/adapters/mcp-adapter.ts index d7b92bf0..b116d855 100644 --- a/packages/mcp/src/adapters/mcp-adapter.ts +++ b/packages/mcp/src/adapters/mcp-adapter.ts @@ -14,14 +14,19 @@ import { logger } from '../server/index.js'; import type { AddDevlogNoteArgs, CreateDevlogArgs, + DeleteDocumentArgs, FindRelatedDevlogsArgs, GetCurrentProjectArgs, GetDevlogArgs, + GetDocumentArgs, ListDevlogArgs, ListDevlogNotesArgs, + ListDocumentsArgs, ListProjectsArgs, + SearchDocumentsArgs, SwitchProjectArgs, UpdateDevlogArgs, + UploadDocumentArgs, } from '../schemas/index.js'; /** @@ -371,4 +376,196 @@ export class MCPAdapter { return this.handleError('Failed to switch project', error); } } + + // === DOCUMENT OPERATIONS === + + async uploadDocument(args: UploadDocumentArgs): Promise { + await this.ensureInitialized(); + + try { + // Decode base64 content + const content = Buffer.from(args.content, 'base64'); + const size = content.length; + + // Validate file size (10MB limit) + const maxSize = 10 * 1024 * 1024; + if (size > maxSize) { + return this.toStandardResponse(false, null, 'File size exceeds 10MB limit'); + } + + // Prepare form data for upload + const formData = new FormData(); + const file = new Blob([content], { type: args.mimeType }); + formData.append('file', file, args.filename); + + if (args.metadata) { + formData.append('metadata', JSON.stringify(args.metadata)); + } + + // Upload document via API client + const result = await this.apiClient.uploadDocument(args.devlogId, formData); + + return this.toStandardResponse( + true, + result, + `Document "${args.filename}" uploaded successfully to devlog ${args.devlogId}`, + ); + } catch (error) { + return this.handleError('Failed to upload document', error); + } + } + + async listDocuments(args: ListDocumentsArgs): Promise { + await this.ensureInitialized(); + + try { + const documents = await this.apiClient.listDocuments(args.devlogId); + + // Apply limit if specified + const limitedDocuments = args.limit ? documents.slice(0, args.limit) : documents; + + return this.toStandardResponse( + true, + { documents: limitedDocuments, total: documents.length }, + `Found ${documents.length} document(s) for devlog ${args.devlogId}`, + ); + } catch (error) { + return this.handleError('Failed to list documents', error); + } + } + + async getDocument(args: GetDocumentArgs): Promise { + await this.ensureInitialized(); + + try { + // For getDocument, we need to find which devlog contains the document + // This is a limitation of the current API design - we'll try a simple approach + // by searching through recent devlogs + const devlogs = await this.apiClient.listDevlogs({ + page: 1, + limit: 20, + sortBy: 'updatedAt', + sortOrder: 'desc' + }); + + let document = null; + for (const devlog of devlogs.items || []) { + try { + document = await this.apiClient.getDocument(devlog.id!, args.documentId); + break; + } catch (err) { + // Document not found in this devlog, continue searching + continue; + } + } + + if (!document) { + return this.toStandardResponse(false, null, `Document ${args.documentId} not found`); + } + + return this.toStandardResponse( + true, + document, + `Retrieved document: ${document.originalName || args.documentId}`, + ); + } catch (error) { + return this.handleError('Failed to get document', error); + } + } + + async deleteDocument(args: DeleteDocumentArgs): Promise { + await this.ensureInitialized(); + + try { + // Similar to getDocument, search through devlogs to find the document + const devlogs = await this.apiClient.listDevlogs({ + page: 1, + limit: 20, + sortBy: 'updatedAt', + sortOrder: 'desc' + }); + + let deleted = false; + for (const devlog of devlogs.items || []) { + try { + await this.apiClient.deleteDocument(devlog.id!, args.documentId); + deleted = true; + break; + } catch (err) { + // Document not found in this devlog, continue searching + continue; + } + } + + if (!deleted) { + return this.toStandardResponse(false, null, `Document ${args.documentId} not found`); + } + + return this.toStandardResponse( + true, + { documentId: args.documentId }, + `Document ${args.documentId} deleted successfully`, + ); + } catch (error) { + return this.handleError('Failed to delete document', error); + } + } + + async searchDocuments(args: SearchDocumentsArgs): Promise { + await this.ensureInitialized(); + + try { + let documents: any[] = []; + + if (args.devlogId) { + // Search within specific devlog + const allDocuments = await this.apiClient.listDocuments(args.devlogId); + + // Filter documents by query + documents = allDocuments.filter((doc: any) => + doc.originalName?.toLowerCase().includes(args.query.toLowerCase()) || + (doc.content && doc.content.toLowerCase().includes(args.query.toLowerCase())) || + doc.filename?.toLowerCase().includes(args.query.toLowerCase()) + ); + } else { + // Search across all recent devlogs + const devlogs = await this.apiClient.listDevlogs({ + page: 1, + limit: 10, + sortBy: 'updatedAt', + sortOrder: 'desc' + }); + + for (const devlog of devlogs.items || []) { + try { + const devlogDocuments = await this.apiClient.listDocuments(devlog.id!); + + const matchingDocs = devlogDocuments.filter((doc: any) => + doc.originalName?.toLowerCase().includes(args.query.toLowerCase()) || + (doc.content && doc.content.toLowerCase().includes(args.query.toLowerCase())) || + doc.filename?.toLowerCase().includes(args.query.toLowerCase()) + ); + + documents.push(...matchingDocs); + } catch (err) { + // Continue with other devlogs if one fails + console.warn(`Failed to search documents in devlog ${devlog.id}:`, err); + } + } + } + + // Apply limit + const limitedDocuments = args.limit ? documents.slice(0, args.limit) : documents; + + return this.toStandardResponse( + true, + { documents: limitedDocuments, total: documents.length }, + `Found ${documents.length} document(s) matching "${args.query}"`, + ); + } catch (error) { + return this.handleError('Failed to search documents', error); + } + } + + // === HELPER METHODS === } diff --git a/packages/mcp/src/api/devlog-api-client.ts b/packages/mcp/src/api/devlog-api-client.ts index 78068e0d..5f45a0be 100644 --- a/packages/mcp/src/api/devlog-api-client.ts +++ b/packages/mcp/src/api/devlog-api-client.ts @@ -355,6 +355,44 @@ export class DevlogApiClient { return this.unwrapApiResponse(response); } + // Document Operations + async uploadDocument( + devlogId: number, + formData: FormData, + ): Promise { + // Use axios to upload form data directly + const response = await this.axiosInstance.post( + `${this.getProjectEndpoint()}/devlogs/${devlogId}/documents`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + return this.unwrapApiResponse(response.data); + } + + async listDocuments(devlogId: number): Promise { + const response = await this.get(`${this.getProjectEndpoint()}/devlogs/${devlogId}/documents`); + const result = this.unwrapApiResponse(response); + return (result as any)?.items || result || []; + } + + async getDocument(devlogId: number, documentId: string): Promise { + const response = await this.get( + `${this.getProjectEndpoint()}/devlogs/${devlogId}/documents/${documentId}` + ); + return this.unwrapApiResponse(response); + } + + async deleteDocument(devlogId: number, documentId: string): Promise { + const response = await this.delete( + `${this.getProjectEndpoint()}/devlogs/${devlogId}/documents/${documentId}` + ); + return this.unwrapApiResponse(response); + } + // Health check async healthCheck(): Promise<{ status: string; timestamp: string }> { try { diff --git a/packages/mcp/src/handlers/tool-handlers.ts b/packages/mcp/src/handlers/tool-handlers.ts index 424d960f..bab3961b 100644 --- a/packages/mcp/src/handlers/tool-handlers.ts +++ b/packages/mcp/src/handlers/tool-handlers.ts @@ -9,22 +9,32 @@ import { AddDevlogNoteSchema, type CreateDevlogArgs, CreateDevlogSchema, + type DeleteDocumentArgs, + DeleteDocumentSchema, type FindRelatedDevlogsArgs, FindRelatedDevlogsSchema, type GetCurrentProjectArgs, GetCurrentProjectSchema, type GetDevlogArgs, GetDevlogSchema, + type GetDocumentArgs, + GetDocumentSchema, type ListDevlogArgs, ListDevlogNotesArgs, ListDevlogNotesSchema, ListDevlogSchema, + type ListDocumentsArgs, + ListDocumentsSchema, type ListProjectsArgs, ListProjectsSchema, + type SearchDocumentsArgs, + SearchDocumentsSchema, type SwitchProjectArgs, SwitchProjectSchema, type UpdateDevlogArgs, UpdateDevlogSchema, + type UploadDocumentArgs, + UploadDocumentSchema, } from '../schemas/index.js'; /** @@ -119,4 +129,45 @@ export const toolHandlers = { validateAndHandle(SwitchProjectSchema, args, 'switch_project', (validArgs) => adapter.switchProject(validArgs), ), + + // Document operations + upload_devlog_document: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + UploadDocumentSchema, + args, + 'upload_devlog_document', + (validArgs) => adapter.uploadDocument(validArgs), + ), + + list_devlog_documents: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + ListDocumentsSchema, + args, + 'list_devlog_documents', + (validArgs) => adapter.listDocuments(validArgs), + ), + + get_devlog_document: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + GetDocumentSchema, + args, + 'get_devlog_document', + (validArgs) => adapter.getDocument(validArgs), + ), + + delete_devlog_document: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + DeleteDocumentSchema, + args, + 'delete_devlog_document', + (validArgs) => adapter.deleteDocument(validArgs), + ), + + search_devlog_documents: (adapter: MCPAdapter, args: unknown) => + validateAndHandle( + SearchDocumentsSchema, + args, + 'search_devlog_documents', + (validArgs) => adapter.searchDocuments(validArgs), + ), }; diff --git a/packages/mcp/src/schemas/document-schemas.ts b/packages/mcp/src/schemas/document-schemas.ts new file mode 100644 index 00000000..777232b1 --- /dev/null +++ b/packages/mcp/src/schemas/document-schemas.ts @@ -0,0 +1,83 @@ +/** + * Document operation schemas for MCP tools - AI-friendly validation + */ + +import { z } from 'zod'; +import { DevlogIdSchema, LimitSchema } from './base.js'; + +// === BASE SCHEMAS === + +export const DocumentIdSchema = z.string().min(1, 'Document ID is required'); + +export const DocumentTypeSchema = z.enum([ + 'text', + 'markdown', + 'image', + 'pdf', + 'code', + 'json', + 'csv', + 'log', + 'config', + 'other' +]).describe('Type of document based on content and file extension'); + +export const FileContentSchema = z.string().describe('Base64-encoded file content for upload'); + +export const FilenameSchema = z.string() + .min(1, 'Filename is required') + .max(255, 'Filename must be 255 characters or less') + .describe('Original filename with extension'); + +export const MimeTypeSchema = z.string() + .min(1, 'MIME type is required') + .describe('MIME type of the file (e.g., text/plain, application/pdf)'); + +export const FileSizeSchema = z.number() + .int() + .min(1, 'File size must be positive') + .max(10 * 1024 * 1024, 'File size cannot exceed 10MB') + .describe('File size in bytes'); + +export const DocumentMetadataSchema = z.record(z.any()) + .optional() + .describe('Additional metadata for the document'); + +// === UPLOAD DOCUMENT === +export const UploadDocumentSchema = z.object({ + devlogId: DevlogIdSchema, + filename: FilenameSchema, + content: FileContentSchema, + mimeType: MimeTypeSchema, + metadata: DocumentMetadataSchema, +}); + +// === LIST DOCUMENTS === +export const ListDocumentsSchema = z.object({ + devlogId: DevlogIdSchema, + limit: LimitSchema.optional(), +}); + +// === GET DOCUMENT === +export const GetDocumentSchema = z.object({ + documentId: DocumentIdSchema, +}); + +// === DELETE DOCUMENT === +export const DeleteDocumentSchema = z.object({ + documentId: DocumentIdSchema, +}); + +// === SEARCH DOCUMENTS === +export const SearchDocumentsSchema = z.object({ + query: z.string().min(1, 'Search query is required'), + devlogId: DevlogIdSchema.optional(), + limit: LimitSchema.optional(), +}); + +// === TYPE EXPORTS === +export type UploadDocumentArgs = z.infer; +export type ListDocumentsArgs = z.infer; +export type GetDocumentArgs = z.infer; +export type DeleteDocumentArgs = z.infer; +export type SearchDocumentsArgs = z.infer; \ No newline at end of file diff --git a/packages/mcp/src/schemas/index.ts b/packages/mcp/src/schemas/index.ts index 4f058380..9d487ace 100644 --- a/packages/mcp/src/schemas/index.ts +++ b/packages/mcp/src/schemas/index.ts @@ -12,3 +12,6 @@ export * from './devlog-schemas.js'; // Project operation schemas export * from './project-schemas.js'; + +// Document operation schemas +export * from './document-schemas.js'; diff --git a/packages/mcp/src/tools/document-tools.ts b/packages/mcp/src/tools/document-tools.ts new file mode 100644 index 00000000..73792a82 --- /dev/null +++ b/packages/mcp/src/tools/document-tools.ts @@ -0,0 +1,50 @@ +import { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { zodToJsonSchema } from '../utils/schema-converter.js'; +import { + UploadDocumentSchema, + ListDocumentsSchema, + GetDocumentSchema, + DeleteDocumentSchema, + SearchDocumentsSchema, +} from '../schemas/index.js'; + +/** + * Document tools for AI agents to manage files and attachments + * + * DESIGN PRINCIPLES: + * - Clear document-specific naming (upload_document, list_documents, etc.) + * - Support for various file types with automatic type detection + * - Content extraction for searchable document types + * - Association with devlog entries for context + */ +export const documentTools: Tool[] = [ + { + name: 'upload_devlog_document', + description: 'Upload and attach a document to a devlog entry (supports text, images, PDFs, code files, etc.)', + inputSchema: zodToJsonSchema(UploadDocumentSchema), + }, + + { + name: 'list_devlog_documents', + description: 'List all documents attached to a specific devlog entry', + inputSchema: zodToJsonSchema(ListDocumentsSchema), + }, + + { + name: 'get_devlog_document', + description: 'Get detailed information about a specific document including content if available', + inputSchema: zodToJsonSchema(GetDocumentSchema), + }, + + { + name: 'delete_devlog_document', + description: 'Delete a document attachment from a devlog entry', + inputSchema: zodToJsonSchema(DeleteDocumentSchema), + }, + + { + name: 'search_devlog_documents', + description: 'Search through document content and filenames across devlog entries', + inputSchema: zodToJsonSchema(SearchDocumentsSchema), + }, +]; \ No newline at end of file diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index 48c5c89f..23f68b33 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -1,21 +1,24 @@ import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { devlogTools } from './devlog-tools.js'; import { projectTools } from './project-tools.js'; +import { documentTools } from './document-tools.js'; /** * All available MCP tools - devlog-specific naming * * See server description for complete terminology and context. * - * Total: 10 tools + * Total: 15 tools * - 7 devlog tools: create_devlog, get_devlog, update_devlog, list_devlogs, * add_devlog_note, complete_devlog, find_related_devlogs * - 3 project tools: list_projects, get_current_project, switch_project + * - 5 document tools: upload_devlog_document, list_devlog_documents, + * get_devlog_document, delete_devlog_document, search_devlog_documents */ -export const allTools: Tool[] = [...devlogTools, ...projectTools]; +export const allTools: Tool[] = [...devlogTools, ...projectTools, ...documentTools]; // Re-export tool groups -export { devlogTools, projectTools }; +export { devlogTools, projectTools, documentTools }; // Simplified tool categories export const coreTools = devlogTools.filter((tool) => @@ -27,3 +30,5 @@ export const actionTools = devlogTools.filter((tool) => ); export const contextTools = projectTools; // Project tools provide AI agent context + +export const fileTools = documentTools; // Document tools for file management