/** * Drawing Tools Service * ===================== * Manages chart drawing tools and templates using PostgreSQL * Tables: trading.drawing_tools + trading.drawing_templates */ import { db } from '../../../shared/database'; import { logger } from '../../../shared/utils/logger'; import type { DrawingTool, DrawingTemplate, DrawingStyle, DrawingPoint, CreateDrawingInput, UpdateDrawingInput, DrawingFilters, CreateTemplateInput, UpdateTemplateInput, TemplateFilters, } from '../types/drawing.types'; // ============================================================================ // Constants // ============================================================================ const DEFAULT_STYLE: DrawingStyle = { color: '#2196F3', lineWidth: 1, lineStyle: 'solid', fillColor: null, fillOpacity: 0.2, showLabel: true, labelPosition: 'right', }; // ============================================================================ // Transform Functions // ============================================================================ function transformDrawing(row: Record): DrawingTool { return { id: row.id as string, userId: row.user_id as string, symbolId: row.symbol_id as string, timeframe: row.timeframe as string, toolType: row.tool_type as DrawingTool['toolType'], name: row.name as string | null, points: row.points as DrawingPoint[], style: row.style as DrawingStyle, fibLevels: row.fib_levels as number[] | null, textContent: row.text_content as string | null, isVisible: row.is_visible as boolean, isLocked: row.is_locked as boolean, zIndex: row.z_index as number, isTemplate: row.is_template as boolean, isShared: row.is_shared as boolean, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } function transformTemplate(row: Record): DrawingTemplate { return { id: row.id as string, userId: row.user_id as string | null, name: row.name as string, description: row.description as string | null, toolType: row.tool_type as DrawingTemplate['toolType'], style: row.style as DrawingStyle, fibLevels: row.fib_levels as number[] | null, isSystem: row.is_system as boolean, isPublic: row.is_public as boolean, usageCount: row.usage_count as number, createdAt: new Date(row.created_at as string), updatedAt: new Date(row.updated_at as string), }; } // ============================================================================ // Drawing Service Class // ============================================================================ class DrawingService { // ========================================================================== // Drawing Tools CRUD // ========================================================================== /** * Get all drawings for a specific chart (symbol + timeframe) */ async getDrawingsForChart( userId: string, symbolId: string, timeframe: string ): Promise { const result = await db.query>( `SELECT * FROM trading.drawing_tools WHERE user_id = $1 AND symbol_id = $2 AND timeframe = $3 AND is_visible = true ORDER BY z_index ASC`, [userId, symbolId, timeframe] ); logger.debug('[DrawingService] Retrieved drawings for chart:', { userId, symbolId, timeframe, count: result.rows.length, }); return result.rows.map(transformDrawing); } /** * Get all drawings for a user with optional filters */ async getUserDrawings(userId: string, filters: DrawingFilters = {}): Promise { const conditions: string[] = ['user_id = $1']; const params: (string | boolean)[] = [userId]; let paramIndex = 2; if (filters.symbolId) { conditions.push(`symbol_id = $${paramIndex++}`); params.push(filters.symbolId); } if (filters.timeframe) { conditions.push(`timeframe = $${paramIndex++}`); params.push(filters.timeframe); } if (filters.toolType) { conditions.push(`tool_type = $${paramIndex++}`); params.push(filters.toolType); } if (filters.isVisible !== undefined) { conditions.push(`is_visible = $${paramIndex++}`); params.push(filters.isVisible); } if (filters.isLocked !== undefined) { conditions.push(`is_locked = $${paramIndex++}`); params.push(filters.isLocked); } if (filters.isTemplate !== undefined) { conditions.push(`is_template = $${paramIndex++}`); params.push(filters.isTemplate); } if (filters.isShared !== undefined) { conditions.push(`is_shared = $${paramIndex++}`); params.push(filters.isShared); } const result = await db.query>( `SELECT * FROM trading.drawing_tools WHERE ${conditions.join(' AND ')} ORDER BY created_at DESC`, params ); return result.rows.map(transformDrawing); } /** * Get a single drawing by ID */ async getDrawingById(id: string, userId: string): Promise { const result = await db.query>( `SELECT * FROM trading.drawing_tools WHERE id = $1 AND user_id = $2`, [id, userId] ); if (result.rows.length === 0) return null; return transformDrawing(result.rows[0]); } /** * Create a new drawing tool */ async createDrawing(userId: string, input: CreateDrawingInput): Promise { const style = { ...DEFAULT_STYLE, ...input.style }; const result = await db.query>( `INSERT INTO trading.drawing_tools (user_id, symbol_id, timeframe, tool_type, name, points, style, fib_levels, text_content) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [ userId, input.symbolId, input.timeframe, input.toolType, input.name || null, JSON.stringify(input.points), JSON.stringify(style), input.fibLevels || null, input.textContent || null, ] ); logger.info('[DrawingService] Drawing created:', { userId, symbolId: input.symbolId, toolType: input.toolType, drawingId: result.rows[0].id, }); return transformDrawing(result.rows[0]); } /** * Update an existing drawing */ async updateDrawing( id: string, userId: string, updates: UpdateDrawingInput ): Promise { const fields: string[] = []; const values: (string | number | boolean | null | number[])[] = []; let paramIndex = 1; if (updates.name !== undefined) { fields.push(`name = $${paramIndex++}`); values.push(updates.name); } if (updates.points !== undefined) { fields.push(`points = $${paramIndex++}`); values.push(JSON.stringify(updates.points)); } if (updates.style !== undefined) { // Merge with existing style - fetch current first const current = await this.getDrawingById(id, userId); if (!current) return null; const mergedStyle = { ...current.style, ...updates.style }; fields.push(`style = $${paramIndex++}`); values.push(JSON.stringify(mergedStyle)); } if (updates.fibLevels !== undefined) { fields.push(`fib_levels = $${paramIndex++}`); values.push(updates.fibLevels); } if (updates.textContent !== undefined) { fields.push(`text_content = $${paramIndex++}`); values.push(updates.textContent); } if (updates.isVisible !== undefined) { fields.push(`is_visible = $${paramIndex++}`); values.push(updates.isVisible); } if (updates.isLocked !== undefined) { fields.push(`is_locked = $${paramIndex++}`); values.push(updates.isLocked); } if (updates.zIndex !== undefined) { fields.push(`z_index = $${paramIndex++}`); values.push(updates.zIndex); } if (updates.isTemplate !== undefined) { fields.push(`is_template = $${paramIndex++}`); values.push(updates.isTemplate); } if (updates.isShared !== undefined) { fields.push(`is_shared = $${paramIndex++}`); values.push(updates.isShared); } if (fields.length === 0) return null; values.push(id, userId); const result = await db.query>( `UPDATE trading.drawing_tools SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`, values ); if (result.rows.length === 0) return null; logger.debug('[DrawingService] Drawing updated:', { id, userId }); return transformDrawing(result.rows[0]); } /** * Delete a drawing */ async deleteDrawing(id: string, userId: string): Promise { const result = await db.query( 'DELETE FROM trading.drawing_tools WHERE id = $1 AND user_id = $2', [id, userId] ); const deleted = (result.rowCount ?? 0) > 0; if (deleted) { logger.info('[DrawingService] Drawing deleted:', { id, userId }); } return deleted; } /** * Delete all drawings for a chart */ async deleteAllForChart( userId: string, symbolId: string, timeframe: string ): Promise { const result = await db.query( `DELETE FROM trading.drawing_tools WHERE user_id = $1 AND symbol_id = $2 AND timeframe = $3`, [userId, symbolId, timeframe] ); const count = result.rowCount ?? 0; logger.info('[DrawingService] Deleted all drawings for chart:', { userId, symbolId, timeframe, count, }); return count; } /** * Batch update z-index for multiple drawings (reordering) */ async reorderDrawings( userId: string, updates: Array<{ id: string; zIndex: number }> ): Promise { // Use a transaction for batch updates const client = await db.getClient(); try { await client.query('BEGIN'); for (const update of updates) { await client.query( `UPDATE trading.drawing_tools SET z_index = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3`, [update.zIndex, update.id, userId] ); } await client.query('COMMIT'); logger.debug('[DrawingService] Drawings reordered:', { userId, count: updates.length }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } // ========================================================================== // Drawing Templates CRUD // ========================================================================== /** * Get available templates for a user (own + system + public) */ async getTemplates(userId: string, filters: TemplateFilters = {}): Promise { const conditions: string[] = ['(user_id = $1 OR is_system = true OR is_public = true)']; const params: (string | boolean)[] = [userId]; let paramIndex = 2; if (filters.toolType) { conditions.push(`tool_type = $${paramIndex++}`); params.push(filters.toolType); } if (filters.isSystem !== undefined) { conditions.push(`is_system = $${paramIndex++}`); params.push(filters.isSystem); } if (filters.isPublic !== undefined) { conditions.push(`is_public = $${paramIndex++}`); params.push(filters.isPublic); } const result = await db.query>( `SELECT * FROM trading.drawing_templates WHERE ${conditions.join(' AND ')} ORDER BY is_system DESC, usage_count DESC, name ASC`, params ); return result.rows.map(transformTemplate); } /** * Get a template by ID */ async getTemplateById(id: string): Promise { const result = await db.query>( `SELECT * FROM trading.drawing_templates WHERE id = $1`, [id] ); if (result.rows.length === 0) return null; return transformTemplate(result.rows[0]); } /** * Create a new template */ async createTemplate(userId: string, input: CreateTemplateInput): Promise { const result = await db.query>( `INSERT INTO trading.drawing_templates (user_id, name, description, tool_type, style, fib_levels, is_public) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ userId, input.name, input.description || null, input.toolType, JSON.stringify(input.style), input.fibLevels || null, input.isPublic ?? false, ] ); logger.info('[DrawingService] Template created:', { userId, name: input.name, toolType: input.toolType, templateId: result.rows[0].id, }); return transformTemplate(result.rows[0]); } /** * Update a template (only owner can update) */ async updateTemplate( id: string, userId: string, updates: UpdateTemplateInput ): Promise { const fields: string[] = []; const values: (string | number | boolean | null | number[])[] = []; let paramIndex = 1; if (updates.name !== undefined) { fields.push(`name = $${paramIndex++}`); values.push(updates.name); } if (updates.description !== undefined) { fields.push(`description = $${paramIndex++}`); values.push(updates.description); } if (updates.style !== undefined) { // Merge with existing style const current = await this.getTemplateById(id); if (!current || current.userId !== userId) return null; const mergedStyle = { ...current.style, ...updates.style }; fields.push(`style = $${paramIndex++}`); values.push(JSON.stringify(mergedStyle)); } if (updates.fibLevels !== undefined) { fields.push(`fib_levels = $${paramIndex++}`); values.push(updates.fibLevels); } if (updates.isPublic !== undefined) { fields.push(`is_public = $${paramIndex++}`); values.push(updates.isPublic); } if (fields.length === 0) return null; values.push(id, userId); const result = await db.query>( `UPDATE trading.drawing_templates SET ${fields.join(', ')}, updated_at = NOW() WHERE id = $${paramIndex++} AND user_id = $${paramIndex} AND is_system = false RETURNING *`, values ); if (result.rows.length === 0) return null; logger.debug('[DrawingService] Template updated:', { id, userId }); return transformTemplate(result.rows[0]); } /** * Delete a template (only owner can delete non-system templates) */ async deleteTemplate(id: string, userId: string): Promise { const result = await db.query( `DELETE FROM trading.drawing_templates WHERE id = $1 AND user_id = $2 AND is_system = false`, [id, userId] ); const deleted = (result.rowCount ?? 0) > 0; if (deleted) { logger.info('[DrawingService] Template deleted:', { id, userId }); } return deleted; } /** * Increment template usage count (when a template is used) */ async incrementTemplateUsage(id: string): Promise { await db.query( `UPDATE trading.drawing_templates SET usage_count = usage_count + 1, updated_at = NOW() WHERE id = $1`, [id] ); } /** * Apply a template to create a new drawing */ async applyTemplate( userId: string, templateId: string, input: Pick ): Promise { const template = await this.getTemplateById(templateId); if (!template) return null; // Check if user can access this template if (!template.isSystem && !template.isPublic && template.userId !== userId) { return null; } // Create drawing with template style const drawing = await this.createDrawing(userId, { symbolId: input.symbolId, timeframe: input.timeframe, toolType: template.toolType, name: input.name, points: input.points, style: template.style, fibLevels: template.fibLevels || undefined, textContent: input.textContent, }); // Increment usage count await this.incrementTemplateUsage(templateId); return drawing; } // ========================================================================== // Statistics // ========================================================================== /** * Get drawing statistics for a user */ async getUserDrawingStats(userId: string): Promise<{ totalDrawings: number; byToolType: Record; totalTemplates: number; }> { const drawingResult = await db.query<{ tool_type: string; count: string }>( `SELECT tool_type, COUNT(*) as count FROM trading.drawing_tools WHERE user_id = $1 GROUP BY tool_type`, [userId] ); const templateResult = await db.query<{ count: string }>( `SELECT COUNT(*) as count FROM trading.drawing_templates WHERE user_id = $1`, [userId] ); const byToolType: Record = {}; let totalDrawings = 0; for (const row of drawingResult.rows) { const count = parseInt(row.count, 10); byToolType[row.tool_type] = count; totalDrawings += count; } return { totalDrawings, byToolType, totalTemplates: parseInt(templateResult.rows[0]?.count || '0', 10), }; } } export const drawingService = new DrawingService();