trading-platform-backend-v2/src/modules/trading/services/drawing.service.ts
Adrian Flores Cortes 5e03e15916 [TASK-2026-02-03-BACKEND-ENTITIES-SYNC] feat: Create backend services for new DDL tables
Services created:
- education/instructor.service.ts: CRUD for education.instructors
- education/tag.service.ts: Course tags and assignments management
- trading/drawing.service.ts: Chart drawing tools persistence
- ml/prediction-overlay.service.ts: ML prediction overlays CRUD
- payments/refund.service.ts: Stripe refund workflow management

Types added:
- Instructor, CourseTag, CourseTagAssignment interfaces
- DrawingTool, DrawingTemplate, DrawingToolType types
- PredictionOverlay, OverlayType, OverlayStyleConfig
- Refund, RefundStatus, RefundRow interfaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 00:16:49 -06:00

596 lines
17 KiB
TypeScript

/**
* 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<string, unknown>): 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<string, unknown>): 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<DrawingTool[]> {
const result = await db.query<Record<string, unknown>>(
`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<DrawingTool[]> {
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<Record<string, unknown>>(
`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<DrawingTool | null> {
const result = await db.query<Record<string, unknown>>(
`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<DrawingTool> {
const style = { ...DEFAULT_STYLE, ...input.style };
const result = await db.query<Record<string, unknown>>(
`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<DrawingTool | null> {
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<Record<string, unknown>>(
`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<boolean> {
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<number> {
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<void> {
// 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<DrawingTemplate[]> {
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<Record<string, unknown>>(
`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<DrawingTemplate | null> {
const result = await db.query<Record<string, unknown>>(
`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<DrawingTemplate> {
const result = await db.query<Record<string, unknown>>(
`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<DrawingTemplate | null> {
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<Record<string, unknown>>(
`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<boolean> {
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<void> {
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<CreateDrawingInput, 'symbolId' | 'timeframe' | 'points' | 'name' | 'textContent'>
): Promise<DrawingTool | null> {
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<string, number>;
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<string, number> = {};
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();