/** * Document Comments and Annotations * Handles comments, annotations, and collaborative review features */ import { query } from './client'; import { z } from 'zod'; export const DocumentCommentSchema = z.object({ id: z.string().uuid(), document_id: z.string().uuid(), version_id: z.string().uuid().optional(), parent_comment_id: z.string().uuid().optional(), comment_text: z.string(), comment_type: z.enum(['comment', 'suggestion', 'question', 'resolution']), status: z.enum(['open', 'resolved', 'dismissed']), page_number: z.number().int().optional(), x_position: z.number().optional(), y_position: z.number().optional(), highlight_text: z.string().optional(), author_id: z.string().uuid(), resolved_by: z.string().uuid().optional(), resolved_at: z.date().optional(), created_at: z.date(), updated_at: z.date(), }); export type DocumentComment = z.infer; export interface CreateDocumentCommentInput { document_id: string; version_id?: string; parent_comment_id?: string; comment_text: string; comment_type?: 'comment' | 'suggestion' | 'question' | 'resolution'; status?: 'open' | 'resolved' | 'dismissed'; page_number?: number; x_position?: number; y_position?: number; highlight_text?: string; author_id: string; } /** * Create a document comment */ export async function createDocumentComment( input: CreateDocumentCommentInput ): Promise { const result = await query( `INSERT INTO document_comments (document_id, version_id, parent_comment_id, comment_text, comment_type, status, page_number, x_position, y_position, highlight_text, author_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [ input.document_id, input.version_id || null, input.parent_comment_id || null, input.comment_text, input.comment_type || 'comment', input.status || 'open', input.page_number || null, input.x_position || null, input.y_position || null, input.highlight_text || null, input.author_id, ] ); return result.rows[0]!; } /** * Get comment by ID */ export async function getDocumentComment(id: string): Promise { const result = await query( `SELECT * FROM document_comments WHERE id = $1`, [id] ); return result.rows[0] || null; } /** * Get all comments for a document */ export async function getDocumentComments( document_id: string, version_id?: string, include_resolved = false ): Promise { const conditions = ['document_id = $1']; const params: unknown[] = [document_id]; let paramIndex = 2; if (version_id) { conditions.push(`version_id = $${paramIndex++}`); params.push(version_id); } if (!include_resolved) { conditions.push(`status != 'resolved'`); } const result = await query( `SELECT * FROM document_comments WHERE ${conditions.join(' AND ')} ORDER BY created_at ASC`, params ); return result.rows; } /** * Get threaded comments (with replies) */ export interface ThreadedComment extends DocumentComment { replies?: ThreadedComment[]; } export async function getThreadedDocumentComments( document_id: string, version_id?: string ): Promise { const allComments = await getDocumentComments(document_id, version_id, true); // Build tree structure const commentMap = new Map(); const rootComments: ThreadedComment[] = []; // First pass: create map for (const comment of allComments) { commentMap.set(comment.id, { ...comment, replies: [] }); } // Second pass: build tree for (const comment of allComments) { const threaded = commentMap.get(comment.id)!; if (comment.parent_comment_id) { const parent = commentMap.get(comment.parent_comment_id); if (parent) { parent.replies!.push(threaded); } } else { rootComments.push(threaded); } } return rootComments; } /** * Update comment */ export async function updateDocumentComment( id: string, updates: Partial> ): Promise { const fields: string[] = []; const values: unknown[] = []; let paramIndex = 1; if (updates.comment_text !== undefined) { fields.push(`comment_text = $${paramIndex++}`); values.push(updates.comment_text); } if (updates.status !== undefined) { fields.push(`status = $${paramIndex++}`); values.push(updates.status); if (updates.status === 'resolved') { // Get current user from context - for now, we'll need to pass it // This should be handled by the service layer } } if (fields.length === 0) { return getDocumentComment(id); } fields.push(`updated_at = NOW()`); values.push(id); const result = await query( `UPDATE document_comments SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); return result.rows[0] || null; } /** * Resolve comment */ export async function resolveDocumentComment( id: string, resolved_by: string ): Promise { const result = await query( `UPDATE document_comments SET status = 'resolved', resolved_by = $1, resolved_at = NOW(), updated_at = NOW() WHERE id = $2 RETURNING *`, [resolved_by, id] ); return result.rows[0] || null; } /** * Get comment statistics for a document */ export interface CommentStatistics { total: number; open: number; resolved: number; dismissed: number; by_type: Record; } export async function getDocumentCommentStatistics( document_id: string ): Promise { const result = await query<{ total: string; open: string; resolved: string; dismissed: string; comment_type: string; count: string; }>( `SELECT COUNT(*) as total, COUNT(*) FILTER (WHERE status = 'open') as open, COUNT(*) FILTER (WHERE status = 'resolved') as resolved, COUNT(*) FILTER (WHERE status = 'dismissed') as dismissed, comment_type, COUNT(*) as count FROM document_comments WHERE document_id = $1 GROUP BY comment_type`, [document_id] ); const stats: CommentStatistics = { total: parseInt(result.rows[0]?.total || '0', 10), open: parseInt(result.rows[0]?.open || '0', 10), resolved: parseInt(result.rows[0]?.resolved || '0', 10), dismissed: parseInt(result.rows[0]?.dismissed || '0', 10), by_type: {}, }; for (const row of result.rows) { stats.by_type[row.comment_type] = parseInt(row.count, 10); } return stats; }