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>
596 lines
17 KiB
TypeScript
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();
|