[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:
Adrian Flores Cortes 2026-02-04 00:16:49 -06:00
parent 85803d92fe
commit 5e03e15916
10 changed files with 2010 additions and 1 deletions

View 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();

View 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();

View File

@ -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;
}

View File

@ -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';

View 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();

View File

@ -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;
}

View 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,
};

View File

@ -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;
}

View 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();

View 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;
}