[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>
This commit is contained in:
parent
85803d92fe
commit
5e03e15916
255
src/modules/education/services/instructor.service.ts
Normal file
255
src/modules/education/services/instructor.service.ts
Normal file
@ -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<string, unknown>): 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<string, string>) || {},
|
||||
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<Instructor | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'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<Instructor | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'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<Instructor> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<Instructor | null> {
|
||||
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<Record<string, unknown>>(
|
||||
`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<boolean> {
|
||||
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<Instructor[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<Instructor[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<Instructor[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<Instructor | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<Instructor | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<void> {
|
||||
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();
|
||||
110
src/modules/education/services/tag.service.ts
Normal file
110
src/modules/education/services/tag.service.ts
Normal file
@ -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<string, unknown>): 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<CourseTag[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'SELECT * FROM education.course_tags ORDER BY usage_count DESC, name ASC'
|
||||
);
|
||||
return result.rows.map(transformTag);
|
||||
}
|
||||
|
||||
async getFeaturedTags(): Promise<CourseTag[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'SELECT * FROM education.course_tags WHERE is_featured = true ORDER BY usage_count DESC'
|
||||
);
|
||||
return result.rows.map(transformTag);
|
||||
}
|
||||
|
||||
async getTagBySlug(slug: string): Promise<CourseTag | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'SELECT * FROM education.course_tags WHERE slug = $1',
|
||||
[slug]
|
||||
);
|
||||
return result.rows[0] ? transformTag(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async getTagById(id: string): Promise<CourseTag | null> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
'SELECT * FROM education.course_tags WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? transformTag(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
async createTag(input: CreateTagInput): Promise<CourseTag> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<CourseTag[]> {
|
||||
const result = await db.query<Record<string, unknown>>(
|
||||
`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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
@ -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<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
export interface UpdateInstructorInput {
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
expertise?: string[];
|
||||
socialLinks?: Record<string, string>;
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
435
src/modules/ml/services/prediction-overlay.service.ts
Normal file
435
src/modules/ml/services/prediction-overlay.service.ts
Normal file
@ -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<string, unknown> | null;
|
||||
style_config: OverlayStyleConfig;
|
||||
metadata: Record<string, unknown>;
|
||||
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<PredictionOverlay[]> {
|
||||
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<OverlayRow>(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<PredictionOverlay[]> {
|
||||
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<OverlayRow>(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<PredictionOverlay | null> {
|
||||
const query = 'SELECT * FROM ml.prediction_overlays WHERE id = $1';
|
||||
|
||||
try {
|
||||
const result = await db.query<OverlayRow>(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<PredictionOverlay[]> {
|
||||
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<OverlayRow>(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<PredictionOverlay> {
|
||||
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<OverlayRow>(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<PredictionOverlay | null> {
|
||||
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<OverlayRow>(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<boolean> {
|
||||
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<PredictionOverlay[]> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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();
|
||||
@ -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<string, unknown> | null;
|
||||
styleConfig: OverlayStyleConfig;
|
||||
metadata: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
styleConfig?: Partial<OverlayStyleConfig>;
|
||||
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;
|
||||
}
|
||||
|
||||
177
src/modules/payments/services/refund.service.ts
Normal file
177
src/modules/payments/services/refund.service.ts
Normal file
@ -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<Refund | null> {
|
||||
const result = await db.query<RefundRow>(
|
||||
'SELECT * FROM financial.refunds WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0] ? transformRefund(result.rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getRefundsForPayment(paymentId: string): Promise<Refund[]> {
|
||||
const result = await db.query<RefundRow>(
|
||||
'SELECT * FROM financial.refunds WHERE payment_id = $1 ORDER BY created_at DESC',
|
||||
[paymentId]
|
||||
);
|
||||
return result.rows.map(transformRefund);
|
||||
}
|
||||
|
||||
export async function getPendingRefunds(): Promise<Refund[]> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`SELECT * FROM financial.refunds WHERE status = 'pending' ORDER BY created_at ASC`
|
||||
);
|
||||
return result.rows.map(transformRefund);
|
||||
}
|
||||
|
||||
export async function getRefundsByStatus(status: RefundStatus): Promise<Refund[]> {
|
||||
const result = await db.query<RefundRow>(
|
||||
'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<Refund[]> {
|
||||
const result = await db.query<RefundRow>(
|
||||
'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<Refund> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`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<Refund | null> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`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<Refund | null> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`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<Refund | null> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`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<Refund | null> {
|
||||
const result = await db.query<RefundRow>(
|
||||
`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,
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
595
src/modules/trading/services/drawing.service.ts
Normal file
595
src/modules/trading/services/drawing.service.ts
Normal file
@ -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<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();
|
||||
197
src/modules/trading/types/drawing.types.ts
Normal file
197
src/modules/trading/types/drawing.types.ts
Normal file
@ -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<DrawingStyle>;
|
||||
fibLevels?: number[];
|
||||
textContent?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDrawingInput {
|
||||
name?: string;
|
||||
points?: DrawingPoint[];
|
||||
style?: Partial<DrawingStyle>;
|
||||
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<DrawingStyle>;
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user