[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;
|
sortBy?: string;
|
||||||
sortOrder?: 'asc' | 'desc';
|
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
|
// Model registry
|
||||||
export { mlModelRegistryService } from './ml-model-registry.service';
|
export { mlModelRegistryService } from './ml-model-registry.service';
|
||||||
|
|
||||||
// Chart overlays
|
// Chart overlays (visualization from ML Engine)
|
||||||
export { mlOverlayService } from './ml-overlay.service';
|
export { mlOverlayService } from './ml-overlay.service';
|
||||||
|
|
||||||
|
// Prediction overlays (CRUD for ml.prediction_overlays table)
|
||||||
|
export { predictionOverlayService } from './prediction-overlay.service';
|
||||||
|
|
||||||
// Real-time signal streaming
|
// Real-time signal streaming
|
||||||
export { mlSignalStreamService } from './ml-signal-stream.service';
|
export { mlSignalStreamService } from './ml-signal-stream.service';
|
||||||
export type { SignalStreamConfig, StreamedSignal } 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;
|
limit?: number;
|
||||||
offset?: 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;
|
expires_at: Date | null;
|
||||||
removed_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