From 5e03e159164b8d3b93aa0ea928d9cad6c5e3ceab Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Wed, 4 Feb 2026 00:16:49 -0600 Subject: [PATCH] [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 --- .../education/services/instructor.service.ts | 255 ++++++++ src/modules/education/services/tag.service.ts | 110 ++++ .../education/types/education.types.ts | 95 +++ src/modules/ml/services/index.ts | 5 +- .../ml/services/prediction-overlay.service.ts | 435 +++++++++++++ src/modules/ml/types/ml.types.ts | 84 +++ .../payments/services/refund.service.ts | 177 ++++++ src/modules/payments/types/financial.types.ts | 58 ++ .../trading/services/drawing.service.ts | 595 ++++++++++++++++++ src/modules/trading/types/drawing.types.ts | 197 ++++++ 10 files changed, 2010 insertions(+), 1 deletion(-) create mode 100644 src/modules/education/services/instructor.service.ts create mode 100644 src/modules/education/services/tag.service.ts create mode 100644 src/modules/ml/services/prediction-overlay.service.ts create mode 100644 src/modules/payments/services/refund.service.ts create mode 100644 src/modules/trading/services/drawing.service.ts create mode 100644 src/modules/trading/types/drawing.types.ts diff --git a/src/modules/education/services/instructor.service.ts b/src/modules/education/services/instructor.service.ts new file mode 100644 index 0000000..469bf59 --- /dev/null +++ b/src/modules/education/services/instructor.service.ts @@ -0,0 +1,255 @@ +/** + * Instructor Service + * Handles instructor management operations + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import type { + Instructor, + CreateInstructorInput, + UpdateInstructorInput, +} from '../types/education.types'; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function transformInstructor(row: Record): Instructor { + return { + id: row.id as string, + userId: row.user_id as string, + displayName: row.display_name as string, + bio: row.bio as string | null, + avatarUrl: row.avatar_url as string | null, + expertise: (row.expertise as string[]) || [], + socialLinks: (row.social_links as Record) || {}, + totalCourses: (row.total_courses as number) || 0, + totalStudents: (row.total_students as number) || 0, + totalReviews: (row.total_reviews as number) || 0, + averageRating: row.average_rating ? parseFloat(row.average_rating as string) : null, + isVerified: row.is_verified as boolean, + verifiedAt: row.verified_at ? new Date(row.verified_at as string) : null, + isActive: row.is_active as boolean, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +// ============================================================================ +// Instructor Service Class +// ============================================================================ + +class InstructorService { + // ========================================================================== + // CRUD Operations + // ========================================================================== + + async getInstructorById(id: string): Promise { + const result = await db.query>( + 'SELECT * FROM education.instructors WHERE id = $1', + [id] + ); + if (result.rows.length === 0) return null; + return transformInstructor(result.rows[0]); + } + + async getInstructorByUserId(userId: string): Promise { + const result = await db.query>( + 'SELECT * FROM education.instructors WHERE user_id = $1', + [userId] + ); + if (result.rows.length === 0) return null; + return transformInstructor(result.rows[0]); + } + + async createInstructor(input: CreateInstructorInput): Promise { + const result = await db.query>( + `INSERT INTO education.instructors + (user_id, display_name, bio, avatar_url, expertise, social_links) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + input.userId, + input.displayName, + input.bio || null, + input.avatarUrl || null, + input.expertise || [], + JSON.stringify(input.socialLinks || {}), + ] + ); + + logger.info('[InstructorService] Instructor created:', { + instructorId: result.rows[0].id, + userId: input.userId, + displayName: input.displayName, + }); + + return transformInstructor(result.rows[0]); + } + + async updateInstructor(id: string, input: UpdateInstructorInput): Promise { + const updates: string[] = []; + const values: (string | number | boolean | string[] | null)[] = []; + let paramIndex = 1; + + if (input.displayName !== undefined) { + updates.push(`display_name = $${paramIndex++}`); + values.push(input.displayName); + } + if (input.bio !== undefined) { + updates.push(`bio = $${paramIndex++}`); + values.push(input.bio); + } + if (input.avatarUrl !== undefined) { + updates.push(`avatar_url = $${paramIndex++}`); + values.push(input.avatarUrl); + } + if (input.expertise !== undefined) { + updates.push(`expertise = $${paramIndex++}`); + values.push(input.expertise); + } + if (input.socialLinks !== undefined) { + updates.push(`social_links = $${paramIndex++}`); + values.push(JSON.stringify(input.socialLinks)); + } + if (input.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + values.push(input.isActive); + } + + if (updates.length === 0) return this.getInstructorById(id); + + values.push(id); + const result = await db.query>( + `UPDATE education.instructors SET ${updates.join(', ')}, updated_at = NOW() + WHERE id = $${paramIndex} RETURNING *`, + values + ); + + if (result.rows.length === 0) return null; + logger.info('[InstructorService] Instructor updated:', { instructorId: id }); + return transformInstructor(result.rows[0]); + } + + async deleteInstructor(id: string): Promise { + const result = await db.query(`DELETE FROM education.instructors WHERE id = $1`, [id]); + logger.info('[InstructorService] Instructor deleted:', { instructorId: id }); + return (result.rowCount ?? 0) > 0; + } + + // ========================================================================== + // Query Operations + // ========================================================================== + + async getInstructorCourses(instructorId: string): Promise<{ id: string; title: string }[]> { + const result = await db.query<{ id: string; title: string }>( + `SELECT c.id, c.title FROM education.courses c + JOIN education.instructors i ON c.instructor_id = i.user_id + WHERE i.id = $1 AND c.status = 'published' + ORDER BY c.created_at DESC`, + [instructorId] + ); + return result.rows; + } + + async getVerifiedInstructors(limit = 10): Promise { + const result = await db.query>( + `SELECT * FROM education.instructors + WHERE is_verified = true AND is_active = true + ORDER BY average_rating DESC NULLS LAST, total_students DESC + LIMIT $1`, + [limit] + ); + return result.rows.map(transformInstructor); + } + + async getAllInstructors(limit = 50, offset = 0): Promise { + const result = await db.query>( + `SELECT * FROM education.instructors + WHERE is_active = true + ORDER BY created_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return result.rows.map(transformInstructor); + } + + async getTopInstructors(limit = 10): Promise { + const result = await db.query>( + `SELECT * FROM education.instructors + WHERE is_active = true + ORDER BY average_rating DESC NULLS LAST, total_courses DESC, total_students DESC + LIMIT $1`, + [limit] + ); + return result.rows.map(transformInstructor); + } + + // ========================================================================== + // Verification Operations + // ========================================================================== + + async verifyInstructor(id: string): Promise { + const result = await db.query>( + `UPDATE education.instructors + SET is_verified = true, verified_at = NOW(), updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id] + ); + + if (result.rows.length === 0) return null; + logger.info('[InstructorService] Instructor verified:', { instructorId: id }); + return transformInstructor(result.rows[0]); + } + + async unverifyInstructor(id: string): Promise { + const result = await db.query>( + `UPDATE education.instructors + SET is_verified = false, verified_at = NULL, updated_at = NOW() + WHERE id = $1 RETURNING *`, + [id] + ); + + if (result.rows.length === 0) return null; + logger.info('[InstructorService] Instructor unverified:', { instructorId: id }); + return transformInstructor(result.rows[0]); + } + + // ========================================================================== + // Statistics Operations + // ========================================================================== + + async updateInstructorStats(instructorId: string): Promise { + await db.query( + `UPDATE education.instructors i + SET + total_courses = ( + SELECT COUNT(*) FROM education.courses c + WHERE c.instructor_id = i.user_id AND c.status = 'published' + ), + total_students = ( + SELECT COUNT(DISTINCT e.user_id) FROM education.enrollments e + JOIN education.courses c ON e.course_id = c.id + WHERE c.instructor_id = i.user_id + ), + total_reviews = ( + SELECT COUNT(*) FROM education.course_reviews r + JOIN education.courses c ON r.course_id = c.id + WHERE c.instructor_id = i.user_id AND r.is_approved = true + ), + average_rating = ( + SELECT AVG(r.rating) FROM education.course_reviews r + JOIN education.courses c ON r.course_id = c.id + WHERE c.instructor_id = i.user_id AND r.is_approved = true + ), + updated_at = NOW() + WHERE i.id = $1`, + [instructorId] + ); + + logger.info('[InstructorService] Instructor stats updated:', { instructorId }); + } +} + +export const instructorService = new InstructorService(); diff --git a/src/modules/education/services/tag.service.ts b/src/modules/education/services/tag.service.ts new file mode 100644 index 0000000..84b3e67 --- /dev/null +++ b/src/modules/education/services/tag.service.ts @@ -0,0 +1,110 @@ +/** + * Tag Service + * Handles course tags and tag assignments + */ + +import { db } from '../../../shared/database'; +import type { CourseTag, CreateTagInput } from '../types/education.types'; + +function transformTag(row: Record): CourseTag { + return { + id: row.id as string, + name: row.name as string, + slug: row.slug as string, + description: row.description as string | null, + color: row.color as string, + icon: row.icon as string | null, + isFeatured: row.is_featured as boolean, + usageCount: row.usage_count as number, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + }; +} + +class TagService { + async getAllTags(): Promise { + const result = await db.query>( + 'SELECT * FROM education.course_tags ORDER BY usage_count DESC, name ASC' + ); + return result.rows.map(transformTag); + } + + async getFeaturedTags(): Promise { + const result = await db.query>( + 'SELECT * FROM education.course_tags WHERE is_featured = true ORDER BY usage_count DESC' + ); + return result.rows.map(transformTag); + } + + async getTagBySlug(slug: string): Promise { + const result = await db.query>( + 'SELECT * FROM education.course_tags WHERE slug = $1', + [slug] + ); + return result.rows[0] ? transformTag(result.rows[0]) : null; + } + + async getTagById(id: string): Promise { + const result = await db.query>( + 'SELECT * FROM education.course_tags WHERE id = $1', + [id] + ); + return result.rows[0] ? transformTag(result.rows[0]) : null; + } + + async createTag(input: CreateTagInput): Promise { + const result = await db.query>( + `INSERT INTO education.course_tags (name, slug, description, color, icon, is_featured) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, + [ + input.name, + input.slug, + input.description || null, + input.color || '#6B7280', + input.icon || null, + input.isFeatured || false, + ] + ); + return transformTag(result.rows[0]); + } + + async getTagsForCourse(courseId: string): Promise { + const result = await db.query>( + `SELECT t.* FROM education.course_tags t + JOIN education.course_tag_assignments cta ON t.id = cta.tag_id + WHERE cta.course_id = $1 + ORDER BY t.name`, + [courseId] + ); + return result.rows.map(transformTag); + } + + async assignTagToCourse(courseId: string, tagId: string, assignedBy?: string): Promise { + await db.query( + `INSERT INTO education.course_tag_assignments (course_id, tag_id, assigned_by) + VALUES ($1, $2, $3) + ON CONFLICT (course_id, tag_id) DO NOTHING`, + [courseId, tagId, assignedBy || null] + ); + } + + async removeTagFromCourse(courseId: string, tagId: string): Promise { + await db.query( + 'DELETE FROM education.course_tag_assignments WHERE course_id = $1 AND tag_id = $2', + [courseId, tagId] + ); + } + + async getCoursesWithTag(tagId: string, limit = 20): Promise<{ id: string; title: string }[]> { + const result = await db.query<{ id: string; title: string }>( + `SELECT c.id, c.title FROM education.courses c + JOIN education.course_tag_assignments cta ON c.id = cta.course_id + WHERE cta.tag_id = $1 AND c.status = 'published' + ORDER BY c.created_at DESC LIMIT $2`, + [tagId, limit] + ); + return result.rows; + } +} + +export const tagService = new TagService(); diff --git a/src/modules/education/types/education.types.ts b/src/modules/education/types/education.types.ts index 161a1e3..95ed44d 100644 --- a/src/modules/education/types/education.types.ts +++ b/src/modules/education/types/education.types.ts @@ -884,3 +884,98 @@ export interface PaginationOptions { sortBy?: string; sortOrder?: 'asc' | 'desc'; } + +// ============================================================================ +// Instructor (Aligned with education.instructors DDL) +// ============================================================================ + +export interface Instructor { + id: string; + userId: string; + displayName: string; + bio: string | null; + avatarUrl: string | null; + expertise: string[]; + socialLinks: Record; + totalCourses: number; + totalStudents: number; + totalReviews: number; + averageRating: number | null; + isVerified: boolean; + verifiedAt: Date | null; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** Row type for DB mapping (snake_case) - education.instructors */ +export interface InstructorRow { + id: string; + user_id: string; + display_name: string; + bio: string | null; + avatar_url: string | null; + expertise: string[]; + social_links: Record; + total_courses: number; + total_students: number; + total_reviews: number; + average_rating: string | null; // DECIMAL comes as string from PG + is_verified: boolean; + verified_at: Date | null; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateInstructorInput { + userId: string; + displayName: string; + bio?: string; + avatarUrl?: string; + expertise?: string[]; + socialLinks?: Record; +} + +export interface UpdateInstructorInput { + displayName?: string; + bio?: string; + avatarUrl?: string; + expertise?: string[]; + socialLinks?: Record; + isActive?: boolean; +} + +// ============================================================================ +// Course Tags (Aligned with education.course_tags DDL) +// ============================================================================ + +export interface CourseTag { + id: string; + name: string; + slug: string; + description: string | null; + color: string; + icon: string | null; + isFeatured: boolean; + usageCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTagInput { + name: string; + slug: string; + description?: string; + color?: string; + icon?: string; + isFeatured?: boolean; +} + +export interface CourseTagAssignment { + id: string; + courseId: string; + tagId: string; + assignedAt: Date; + assignedBy: string | null; +} diff --git a/src/modules/ml/services/index.ts b/src/modules/ml/services/index.ts index 71ea65a..519d39f 100644 --- a/src/modules/ml/services/index.ts +++ b/src/modules/ml/services/index.ts @@ -22,9 +22,12 @@ export { mlDataService } from './ml-data.service'; // Model registry export { mlModelRegistryService } from './ml-model-registry.service'; -// Chart overlays +// Chart overlays (visualization from ML Engine) export { mlOverlayService } from './ml-overlay.service'; +// Prediction overlays (CRUD for ml.prediction_overlays table) +export { predictionOverlayService } from './prediction-overlay.service'; + // Real-time signal streaming export { mlSignalStreamService } from './ml-signal-stream.service'; export type { SignalStreamConfig, StreamedSignal } from './ml-signal-stream.service'; diff --git a/src/modules/ml/services/prediction-overlay.service.ts b/src/modules/ml/services/prediction-overlay.service.ts new file mode 100644 index 0000000..e7040a6 --- /dev/null +++ b/src/modules/ml/services/prediction-overlay.service.ts @@ -0,0 +1,435 @@ +/** + * Prediction Overlay Service + * CRUD operations for ml.prediction_overlays table + * Manages chart overlays associated with ML predictions + * + * @see apps/database/ddl/schemas/ml/ + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import type { + PredictionOverlay, + CreateOverlayInput, + UpdateOverlayInput, + OverlayStyleConfig, + OverlayType, +} from '../types/ml.types'; + +// ============================================================================ +// Type Definitions for Query Results +// ============================================================================ + +interface OverlayRow { + id: string; + prediction_id: string; + overlay_type: OverlayType; + label: string | null; + price_levels: number[]; + time_range: { start: string; end: string } | null; + time_point: Date | null; + price_point: string | null; + coordinates: Record | null; + style_config: OverlayStyleConfig; + metadata: Record; + is_active: boolean; + display_priority: number; + z_index: number; + expires_at: Date | null; + created_at: Date; +} + +// ============================================================================ +// Default Style Configuration +// ============================================================================ + +const DEFAULT_STYLE: OverlayStyleConfig = { + color: '#4CAF50', + lineWidth: 2, + lineStyle: 'solid', + fillColor: null, + fillOpacity: 0.3, + labelText: null, + fontSize: 12, +}; + +// ============================================================================ +// Prediction Overlay Service +// ============================================================================ + +class PredictionOverlayService { + // ========================================================================== + // Read Operations + // ========================================================================== + + /** + * Get all overlays for a specific prediction + */ + async getOverlaysForPrediction(predictionId: string): Promise { + const query = ` + SELECT * FROM ml.prediction_overlays + WHERE prediction_id = $1 AND is_active = true + ORDER BY display_priority DESC, z_index ASC + `; + + try { + const result = await db.query(query, [predictionId]); + return result.rows.map((row) => this.mapOverlayRow(row)); + } catch (error) { + logger.error('[PredictionOverlayService] Failed to get overlays for prediction', { + error: (error as Error).message, + predictionId, + }); + throw error; + } + } + + /** + * Get all active overlays for a symbol/timeframe combination + * Joins with predictions table to filter by symbol and timeframe + */ + async getActiveOverlaysForSymbol( + symbol: string, + timeframe: string + ): Promise { + const query = ` + SELECT po.* FROM ml.prediction_overlays po + JOIN ml.predictions p ON po.prediction_id = p.id + WHERE p.symbol = $1 AND p.timeframe = $2 + AND po.is_active = true + AND (po.expires_at IS NULL OR po.expires_at > NOW()) + ORDER BY po.display_priority DESC, po.z_index ASC + `; + + try { + const result = await db.query(query, [symbol, timeframe]); + return result.rows.map((row) => this.mapOverlayRow(row)); + } catch (error) { + logger.error('[PredictionOverlayService] Failed to get overlays for symbol', { + error: (error as Error).message, + symbol, + timeframe, + }); + throw error; + } + } + + /** + * Get a single overlay by ID + */ + async getOverlayById(id: string): Promise { + const query = 'SELECT * FROM ml.prediction_overlays WHERE id = $1'; + + try { + const result = await db.query(query, [id]); + return result.rows[0] ? this.mapOverlayRow(result.rows[0]) : null; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to get overlay by ID', { + error: (error as Error).message, + id, + }); + throw error; + } + } + + /** + * Get overlays by type for a symbol + */ + async getOverlaysByType( + symbol: string, + overlayType: OverlayType + ): Promise { + const query = ` + SELECT po.* FROM ml.prediction_overlays po + JOIN ml.predictions p ON po.prediction_id = p.id + WHERE p.symbol = $1 + AND po.overlay_type = $2 + AND po.is_active = true + AND (po.expires_at IS NULL OR po.expires_at > NOW()) + ORDER BY po.created_at DESC + `; + + try { + const result = await db.query(query, [symbol, overlayType]); + return result.rows.map((row) => this.mapOverlayRow(row)); + } catch (error) { + logger.error('[PredictionOverlayService] Failed to get overlays by type', { + error: (error as Error).message, + symbol, + overlayType, + }); + throw error; + } + } + + // ========================================================================== + // Write Operations + // ========================================================================== + + /** + * Create a new prediction overlay + */ + async createOverlay(input: CreateOverlayInput): Promise { + const style = { ...DEFAULT_STYLE, ...input.styleConfig }; + + const query = ` + INSERT INTO ml.prediction_overlays ( + prediction_id, overlay_type, label, price_levels, time_range, + time_point, price_point, coordinates, style_config, display_priority, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `; + + const params = [ + input.predictionId, + input.overlayType, + input.label || null, + input.priceLevels || [], + input.timeRange ? JSON.stringify(input.timeRange) : null, + input.timePoint || null, + input.pricePoint || null, + input.coordinates ? JSON.stringify(input.coordinates) : null, + JSON.stringify(style), + input.displayPriority || 0, + input.expiresAt || null, + ]; + + try { + const result = await db.query(query, params); + const overlay = this.mapOverlayRow(result.rows[0]); + + logger.info('[PredictionOverlayService] Overlay created', { + id: overlay.id, + predictionId: input.predictionId, + overlayType: input.overlayType, + }); + + return overlay; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to create overlay', { + error: (error as Error).message, + predictionId: input.predictionId, + overlayType: input.overlayType, + }); + throw error; + } + } + + /** + * Update an existing overlay + */ + async updateOverlay( + id: string, + updates: UpdateOverlayInput + ): Promise { + const fields: string[] = []; + const values: (string | number | boolean | number[] | null)[] = []; + let paramIndex = 1; + + if (updates.isActive !== undefined) { + fields.push(`is_active = $${paramIndex++}`); + values.push(updates.isActive); + } + + if (updates.priceLevels) { + fields.push(`price_levels = $${paramIndex++}`); + values.push(updates.priceLevels); + } + + if (updates.styleConfig) { + fields.push(`style_config = $${paramIndex++}`); + values.push(JSON.stringify(updates.styleConfig)); + } + + if (updates.displayPriority !== undefined) { + fields.push(`display_priority = $${paramIndex++}`); + values.push(updates.displayPriority); + } + + if (updates.zIndex !== undefined) { + fields.push(`z_index = $${paramIndex++}`); + values.push(updates.zIndex); + } + + if (updates.expiresAt !== undefined) { + fields.push(`expires_at = $${paramIndex++}`); + values.push(updates.expiresAt); + } + + if (fields.length === 0) { + logger.warn('[PredictionOverlayService] No fields to update', { id }); + return this.getOverlayById(id); + } + + values.push(id); + const query = ` + UPDATE ml.prediction_overlays + SET ${fields.join(', ')} + WHERE id = $${paramIndex} + RETURNING * + `; + + try { + const result = await db.query(query, values); + + if (!result.rows[0]) { + logger.warn('[PredictionOverlayService] Overlay not found for update', { id }); + return null; + } + + const overlay = this.mapOverlayRow(result.rows[0]); + + logger.info('[PredictionOverlayService] Overlay updated', { + id, + fieldsUpdated: fields.length, + }); + + return overlay; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to update overlay', { + error: (error as Error).message, + id, + }); + throw error; + } + } + + /** + * Delete an overlay permanently + */ + async deleteOverlay(id: string): Promise { + const query = 'DELETE FROM ml.prediction_overlays WHERE id = $1'; + + try { + const result = await db.query(query, [id]); + const deleted = (result.rowCount ?? 0) > 0; + + if (deleted) { + logger.info('[PredictionOverlayService] Overlay deleted', { id }); + } else { + logger.warn('[PredictionOverlayService] Overlay not found for deletion', { id }); + } + + return deleted; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to delete overlay', { + error: (error as Error).message, + id, + }); + throw error; + } + } + + // ========================================================================== + // Bulk Operations + // ========================================================================== + + /** + * Create multiple overlays for a prediction + */ + async createBulkOverlays(inputs: CreateOverlayInput[]): Promise { + const overlays: PredictionOverlay[] = []; + + for (const input of inputs) { + const overlay = await this.createOverlay(input); + overlays.push(overlay); + } + + logger.info('[PredictionOverlayService] Bulk overlays created', { + count: overlays.length, + }); + + return overlays; + } + + /** + * Deactivate all expired overlays + * Returns count of deactivated overlays + */ + async deactivateExpiredOverlays(): Promise { + const query = ` + UPDATE ml.prediction_overlays + SET is_active = false + WHERE is_active = true + AND expires_at IS NOT NULL + AND expires_at < NOW() + `; + + try { + const result = await db.query(query); + const count = result.rowCount ?? 0; + + if (count > 0) { + logger.info('[PredictionOverlayService] Expired overlays deactivated', { count }); + } + + return count; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to deactivate expired overlays', { + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Deactivate all overlays for a prediction + */ + async deactivateOverlaysForPrediction(predictionId: string): Promise { + const query = ` + UPDATE ml.prediction_overlays + SET is_active = false + WHERE prediction_id = $1 AND is_active = true + `; + + try { + const result = await db.query(query, [predictionId]); + const count = result.rowCount ?? 0; + + logger.info('[PredictionOverlayService] Overlays deactivated for prediction', { + predictionId, + count, + }); + + return count; + } catch (error) { + logger.error('[PredictionOverlayService] Failed to deactivate overlays', { + error: (error as Error).message, + predictionId, + }); + throw error; + } + } + + // ========================================================================== + // Private Helper Methods + // ========================================================================== + + /** + * Transform database row to PredictionOverlay entity + */ + private mapOverlayRow(row: OverlayRow): PredictionOverlay { + return { + id: row.id, + predictionId: row.prediction_id, + overlayType: row.overlay_type, + label: row.label, + priceLevels: row.price_levels || [], + timeRange: row.time_range, + timePoint: row.time_point ? new Date(row.time_point) : null, + pricePoint: row.price_point ? parseFloat(row.price_point) : null, + coordinates: row.coordinates, + styleConfig: row.style_config, + metadata: row.metadata || {}, + isActive: row.is_active, + displayPriority: row.display_priority, + zIndex: row.z_index, + expiresAt: row.expires_at ? new Date(row.expires_at) : null, + createdAt: new Date(row.created_at), + }; + } +} + +// Export singleton instance +export const predictionOverlayService = new PredictionOverlayService(); diff --git a/src/modules/ml/types/ml.types.ts b/src/modules/ml/types/ml.types.ts index 46d85a0..bf20296 100644 --- a/src/modules/ml/types/ml.types.ts +++ b/src/modules/ml/types/ml.types.ts @@ -436,3 +436,87 @@ export interface ModelQueryOptions { limit?: number; offset?: number; } + +// ============================================================================ +// Prediction Overlays (ml.prediction_overlays table) +// ============================================================================ + +/** + * Overlay type enum - aligned with DDL overlay_type + * Includes ICT (Inner Circle Trader) concepts + */ +export type OverlayType = + | 'support_resistance' + | 'trend_line' + | 'zone' + | 'arrow' + | 'label' + | 'fibonacci' + | 'order_block' + | 'fair_value_gap' + | 'liquidity_level' + | 'ict_killzone'; + +/** + * Style configuration for overlay rendering + */ +export interface OverlayStyleConfig { + color: string; + lineWidth: number; + lineStyle: 'solid' | 'dashed' | 'dotted'; + fillColor: string | null; + fillOpacity: number; + labelText: string | null; + fontSize: number; +} + +/** + * Prediction Overlay entity (ml.prediction_overlays table) + */ +export interface PredictionOverlay { + id: string; + predictionId: string; + overlayType: OverlayType; + label: string | null; + priceLevels: number[]; + timeRange: { start: string; end: string } | null; + timePoint: Date | null; + pricePoint: number | null; + coordinates: Record | null; + styleConfig: OverlayStyleConfig; + metadata: Record; + isActive: boolean; + displayPriority: number; + zIndex: number; + expiresAt: Date | null; + createdAt: Date; +} + +/** + * Input DTO for creating a new overlay + */ +export interface CreateOverlayInput { + predictionId: string; + overlayType: OverlayType; + label?: string; + priceLevels?: number[]; + timeRange?: { start: string; end: string }; + timePoint?: string; + pricePoint?: number; + coordinates?: Record; + styleConfig?: Partial; + displayPriority?: number; + expiresAt?: string; +} + +/** + * Input DTO for updating an existing overlay + */ +export interface UpdateOverlayInput { + isActive?: boolean; + priceLevels?: number[]; + styleConfig?: OverlayStyleConfig; + displayPriority?: number; + zIndex?: number; + expiresAt?: string | null; +} diff --git a/src/modules/payments/services/refund.service.ts b/src/modules/payments/services/refund.service.ts new file mode 100644 index 0000000..a4c8a5d --- /dev/null +++ b/src/modules/payments/services/refund.service.ts @@ -0,0 +1,177 @@ +/** + * Refund Service + * Handles financial.refunds operations with approval flow + */ + +import { db } from '../../../shared/database'; +import type { Refund, RefundRow, RequestRefundInput, RefundStatus } from '../types/financial.types'; + +// ============================================================================ +// Transform Functions +// ============================================================================ + +function transformRefund(row: RefundRow): Refund { + return { + id: row.id, + paymentId: row.payment_id, + amount: parseFloat(row.amount), + currency: row.currency, + reason: row.reason, + reasonCode: row.reason_code, + notes: row.notes, + status: row.status as RefundStatus, + stripeRefundId: row.stripe_refund_id, + stripeFailureReason: row.stripe_failure_reason, + requestedBy: row.requested_by, + approvedBy: row.approved_by, + approvedAt: row.approved_at ? new Date(row.approved_at) : null, + rejectedAt: row.rejected_at ? new Date(row.rejected_at) : null, + rejectionReason: row.rejection_reason, + createdAt: new Date(row.created_at), + processedAt: row.processed_at ? new Date(row.processed_at) : null, + completedAt: row.completed_at ? new Date(row.completed_at) : null, + failedAt: row.failed_at ? new Date(row.failed_at) : null, + }; +} + +// ============================================================================ +// Query Functions +// ============================================================================ + +export async function getRefundById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM financial.refunds WHERE id = $1', + [id] + ); + return result.rows[0] ? transformRefund(result.rows[0]) : null; +} + +export async function getRefundsForPayment(paymentId: string): Promise { + const result = await db.query( + 'SELECT * FROM financial.refunds WHERE payment_id = $1 ORDER BY created_at DESC', + [paymentId] + ); + return result.rows.map(transformRefund); +} + +export async function getPendingRefunds(): Promise { + const result = await db.query( + `SELECT * FROM financial.refunds WHERE status = 'pending' ORDER BY created_at ASC` + ); + return result.rows.map(transformRefund); +} + +export async function getRefundsByStatus(status: RefundStatus): Promise { + const result = await db.query( + 'SELECT * FROM financial.refunds WHERE status = $1 ORDER BY created_at DESC', + [status] + ); + return result.rows.map(transformRefund); +} + +export async function getRefundsByUser(userId: string): Promise { + const result = await db.query( + 'SELECT * FROM financial.refunds WHERE requested_by = $1 ORDER BY created_at DESC', + [userId] + ); + return result.rows.map(transformRefund); +} + +// ============================================================================ +// Mutation Functions +// ============================================================================ + +export async function requestRefund( + requestedBy: string, + input: RequestRefundInput +): Promise { + const result = await db.query( + `INSERT INTO financial.refunds + (payment_id, amount, reason, reason_code, notes, requested_by, status) + VALUES ($1, $2, $3, $4, $5, $6, 'pending') + RETURNING *`, + [ + input.paymentId, + input.amount, + input.reason || null, + input.reasonCode || null, + input.notes || null, + requestedBy, + ] + ); + return transformRefund(result.rows[0]); +} + +export async function approveRefund( + id: string, + approvedBy: string +): Promise { + const result = await db.query( + `UPDATE financial.refunds + SET status = 'processing', approved_by = $2, approved_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id, approvedBy] + ); + return result.rows[0] ? transformRefund(result.rows[0]) : null; +} + +export async function rejectRefund( + id: string, + rejectedBy: string, + reason: string +): Promise { + const result = await db.query( + `UPDATE financial.refunds + SET status = 'cancelled', rejected_at = NOW(), rejection_reason = $3 + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id, rejectedBy, reason] + ); + return result.rows[0] ? transformRefund(result.rows[0]) : null; +} + +export async function completeRefund( + id: string, + stripeRefundId: string +): Promise { + const result = await db.query( + `UPDATE financial.refunds + SET status = 'succeeded', stripe_refund_id = $2, completed_at = NOW(), processed_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, stripeRefundId] + ); + return result.rows[0] ? transformRefund(result.rows[0]) : null; +} + +export async function failRefund( + id: string, + failureReason: string +): Promise { + const result = await db.query( + `UPDATE financial.refunds + SET status = 'failed', stripe_failure_reason = $2, failed_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, failureReason] + ); + return result.rows[0] ? transformRefund(result.rows[0]) : null; +} + +// ============================================================================ +// Refund Service Object (Alternative Export) +// ============================================================================ + +export const refundService = { + getById: getRefundById, + getForPayment: getRefundsForPayment, + getPending: getPendingRefunds, + getByStatus: getRefundsByStatus, + getByUser: getRefundsByUser, + request: requestRefund, + approve: approveRefund, + reject: rejectRefund, + complete: completeRefund, + fail: failRefund, +}; diff --git a/src/modules/payments/types/financial.types.ts b/src/modules/payments/types/financial.types.ts index e81ca2e..7d78aa0 100644 --- a/src/modules/payments/types/financial.types.ts +++ b/src/modules/payments/types/financial.types.ts @@ -555,3 +555,61 @@ export interface PaymentMethodRow { expires_at: Date | null; removed_at: Date | null; } + +// ============================================================================ +// Refunds (from financial.refunds) +// ============================================================================ + +export type RefundStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'cancelled'; + +export interface Refund { + id: string; + paymentId: string; + amount: number; + currency: string; + reason: string | null; + reasonCode: string | null; + notes: string | null; + status: RefundStatus; + stripeRefundId: string | null; + stripeFailureReason: string | null; + requestedBy: string; + approvedBy: string | null; + approvedAt: Date | null; + rejectedAt: Date | null; + rejectionReason: string | null; + createdAt: Date; + processedAt: Date | null; + completedAt: Date | null; + failedAt: Date | null; +} + +export interface RefundRow { + id: string; + payment_id: string; + amount: string; + currency: string; + reason: string | null; + reason_code: string | null; + notes: string | null; + status: string; + stripe_refund_id: string | null; + stripe_failure_reason: string | null; + requested_by: string; + approved_by: string | null; + approved_at: Date | null; + rejected_at: Date | null; + rejection_reason: string | null; + created_at: Date; + processed_at: Date | null; + completed_at: Date | null; + failed_at: Date | null; +} + +export interface RequestRefundInput { + paymentId: string; + amount: number; + reason?: string; + reasonCode?: string; + notes?: string; +} diff --git a/src/modules/trading/services/drawing.service.ts b/src/modules/trading/services/drawing.service.ts new file mode 100644 index 0000000..bc5e2b9 --- /dev/null +++ b/src/modules/trading/services/drawing.service.ts @@ -0,0 +1,595 @@ +/** + * 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(); diff --git a/src/modules/trading/types/drawing.types.ts b/src/modules/trading/types/drawing.types.ts new file mode 100644 index 0000000..78f475c --- /dev/null +++ b/src/modules/trading/types/drawing.types.ts @@ -0,0 +1,197 @@ +/** + * Drawing Types + * ============= + * Type definitions for chart drawing tools + * Aligned with trading.drawing_tools + trading.drawing_templates DDL schema + */ + +// Alineado con trading.drawing_tool_type (DDL 00-enums.sql) +export type DrawingToolType = + | 'trend_line' + | 'horizontal_line' + | 'vertical_line' + | 'ray' + | 'extended_line' + | 'parallel_channel' + | 'fibonacci_retracement' + | 'fibonacci_extension' + | 'rectangle' + | 'ellipse' + | 'triangle' + | 'arrow' + | 'text' + | 'price_range' + | 'date_range' + | 'order_block' + | 'fair_value_gap' + | 'liquidity_level'; + +export enum DrawingToolTypeEnum { + TREND_LINE = 'trend_line', + HORIZONTAL_LINE = 'horizontal_line', + VERTICAL_LINE = 'vertical_line', + RAY = 'ray', + EXTENDED_LINE = 'extended_line', + PARALLEL_CHANNEL = 'parallel_channel', + FIBONACCI_RETRACEMENT = 'fibonacci_retracement', + FIBONACCI_EXTENSION = 'fibonacci_extension', + RECTANGLE = 'rectangle', + ELLIPSE = 'ellipse', + TRIANGLE = 'triangle', + ARROW = 'arrow', + TEXT = 'text', + PRICE_RANGE = 'price_range', + DATE_RANGE = 'date_range', + ORDER_BLOCK = 'order_block', + FAIR_VALUE_GAP = 'fair_value_gap', + LIQUIDITY_LEVEL = 'liquidity_level', +} + +// ============================================================================ +// JSONB Field Types +// ============================================================================ + +/** + * DrawingPoint - Punto de anclaje del dibujo + * Used in: trading.drawing_tools.points (JSONB) + */ +export interface DrawingPoint { + time: string; // ISO timestamp + price: number; +} + +/** + * DrawingStyle - Configuracion visual del dibujo + * Used in: trading.drawing_tools.style (JSONB) + */ +export interface DrawingStyle { + color: string; + lineWidth: number; + lineStyle: 'solid' | 'dashed' | 'dotted'; + fillColor: string | null; + fillOpacity: number; + showLabel: boolean; + labelPosition: 'left' | 'right' | 'top' | 'bottom'; +} + +// ============================================================================ +// DrawingTool Entity (trading.drawing_tools) +// ============================================================================ + +/** + * DrawingTool - Herramientas de dibujo en charts + * Aligned with: trading.drawing_tools DDL + */ +export interface DrawingTool { + id: string; + userId: string; + symbolId: string; + timeframe: string; + toolType: DrawingToolType; + name: string | null; + points: DrawingPoint[]; + style: DrawingStyle; + fibLevels: number[] | null; + textContent: string | null; + isVisible: boolean; + isLocked: boolean; + zIndex: number; + isTemplate: boolean; + isShared: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateDrawingInput { + symbolId: string; + timeframe: string; + toolType: DrawingToolType; + name?: string; + points: DrawingPoint[]; + style?: Partial; + fibLevels?: number[]; + textContent?: string; +} + +export interface UpdateDrawingInput { + name?: string; + points?: DrawingPoint[]; + style?: Partial; + fibLevels?: number[]; + textContent?: string; + isVisible?: boolean; + isLocked?: boolean; + zIndex?: number; + isTemplate?: boolean; + isShared?: boolean; +} + +export interface DrawingFilters { + symbolId?: string; + timeframe?: string; + toolType?: DrawingToolType; + isVisible?: boolean; + isLocked?: boolean; + isTemplate?: boolean; + isShared?: boolean; +} + +// ============================================================================ +// DrawingTemplate Entity (trading.drawing_templates) +// ============================================================================ + +/** + * DrawingTemplate - Templates reutilizables de estilos + * Aligned with: trading.drawing_templates DDL + */ +export interface DrawingTemplate { + id: string; + userId: string | null; + name: string; + description: string | null; + toolType: DrawingToolType; + style: DrawingStyle; + fibLevels: number[] | null; + isSystem: boolean; + isPublic: boolean; + usageCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTemplateInput { + name: string; + description?: string; + toolType: DrawingToolType; + style: DrawingStyle; + fibLevels?: number[]; + isPublic?: boolean; +} + +export interface UpdateTemplateInput { + name?: string; + description?: string; + style?: Partial; + fibLevels?: number[]; + isPublic?: boolean; +} + +export interface TemplateFilters { + toolType?: DrawingToolType; + isSystem?: boolean; + isPublic?: boolean; +} + +// ============================================================================ +// Result Types +// ============================================================================ + +export interface DrawingListResult { + drawings: DrawingTool[]; + total: number; +} + +export interface TemplateListResult { + templates: DrawingTemplate[]; + total: number; +}