/** * Repository for provisioning-related database operations * Uses two databases: * - DB (cloud-instances-db): server specs, pricing, providers * - USER_DB (telegram-conversations): users, deposits, orders */ import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider, OsImage } from '../types'; export class ProvisioningRepository { constructor( private db: D1Database, // cloud-instances-db private userDb: D1Database // telegram-conversations ) {} // ============================================ // User Operations (telegram-conversations.users) // ============================================ async getUserByTelegramId(telegramId: string): Promise { const result = await this.userDb .prepare('SELECT * FROM users WHERE telegram_id = ?') .bind(telegramId) .first(); return result as unknown as TelegramUser | null; } async getUserById(userId: number): Promise { const result = await this.userDb .prepare('SELECT * FROM users WHERE id = ?') .bind(userId) .first(); return result as unknown as TelegramUser | null; } // ============================================ // Deposit Operations (telegram-conversations.user_deposits) // Balance is in KRW (원), INTEGER type // ============================================ async getUserDeposit(userId: number): Promise { const result = await this.userDb .prepare('SELECT * FROM user_deposits WHERE user_id = ?') .bind(userId) .first(); return result as unknown as UserDeposit | null; } async getBalance(userId: number): Promise { const deposit = await this.getUserDeposit(userId); return deposit?.balance ?? 0; } async deductBalance(userId: number, amount: number): Promise { // Atomic balance deduction - prevents race condition const result = await this.userDb .prepare( `UPDATE user_deposits SET balance = balance - ?, updated_at = datetime('now') WHERE user_id = ? AND balance >= ? RETURNING balance` ) .bind(amount, userId, amount) .first(); // If no row returned, balance was insufficient return result !== null; } async refundBalance(userId: number, amount: number): Promise { await this.userDb .prepare( `UPDATE user_deposits SET balance = balance + ?, updated_at = datetime('now') WHERE user_id = ?` ) .bind(amount, userId) .run(); } // ============================================ // Server Order Operations (telegram-conversations.server_orders) // ============================================ async createServerOrder( userId: number, telegramUserId: string, specId: number, region: string, pricePaid: number, label: string | null, image: string | null, idempotencyKey: string | null ): Promise { const result = await this.userDb .prepare( `INSERT INTO server_orders (user_id, telegram_user_id, spec_id, status, region, price_paid, label, image, billing_type, idempotency_key, created_at, expires_at) VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, 'monthly', ?, CURRENT_TIMESTAMP, datetime(CURRENT_TIMESTAMP, '+720 hours')) RETURNING *` ) .bind(userId, telegramUserId, specId, region, pricePaid, label, image, idempotencyKey) .first(); return result as unknown as ServerOrder; } async updateOrderStatus( orderId: number, status: ServerOrder['status'], errorMessage?: string ): Promise { if (errorMessage) { await this.userDb .prepare( `UPDATE server_orders SET status = ?, error_message = ?, updated_at = datetime('now') WHERE id = ?` ) .bind(status, errorMessage, orderId) .run(); } else { await this.userDb .prepare( `UPDATE server_orders SET status = ?, updated_at = datetime('now') WHERE id = ?` ) .bind(status, orderId) .run(); } } /** * Update order with root password * * SECURITY WARNING: Password is currently stored in plaintext. * TODO: Implement encryption using WebCrypto API (AES-GCM) before production use. * The encryption key should be stored in env.ENCRYPTION_KEY secret. */ async updateOrderRootPassword(orderId: number, rootPassword: string): Promise { // TODO: Encrypt password before storage // const encryptedPassword = await this.encryptPassword(rootPassword, env.ENCRYPTION_KEY); await this.userDb .prepare( `UPDATE server_orders SET root_password = ?, updated_at = datetime('now') WHERE id = ?` ) .bind(rootPassword, orderId) .run(); } async updateOrderProviderInfo( orderId: number, providerInstanceId: string, ipAddress: string | null, rootPassword: string ): Promise { await this.userDb .prepare( `UPDATE server_orders SET provider_instance_id = ?, ip_address = ?, root_password = ?, status = 'active', provisioned_at = datetime('now'), updated_at = datetime('now') WHERE id = ?` ) .bind(providerInstanceId, ipAddress, rootPassword, orderId) .run(); } async getOrderById(orderId: number): Promise { const result = await this.userDb .prepare('SELECT * FROM server_orders WHERE id = ?') .bind(orderId) .first(); return result as unknown as ServerOrder | null; } async getOrdersByUserId(userId: number, limit: number = 20): Promise { // Query uses two databases: // - this.userDb (telegram-conversations): server_orders // - this.db (cloud-instances-db): anvil_pricing, anvil_instances // Step 1: Get orders from user DB const ordersResult = await this.userDb .prepare( `SELECT * FROM server_orders WHERE user_id = ? AND status NOT IN ('terminated', 'cancelled') ORDER BY created_at DESC LIMIT ?` ) .bind(userId, limit) .all(); const orders = ordersResult.results as unknown as ServerOrder[]; if (orders.length === 0) { return []; } // Step 2: Get spec details from cloud-instances-db const specIds = orders.map(o => o.spec_id); const placeholders = specIds.map(() => '?').join(','); const specsResult = await this.db .prepare( `SELECT ap.id as spec_id, ai.vcpus as vcpu, ai.memory_gb, ai.disk_gb, ai.transfer_tb as bandwidth_tb, ai.display_name as spec_name FROM anvil_pricing ap JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id WHERE ap.id IN (${placeholders})` ) .bind(...specIds) .all(); // Step 3: Create a map for efficient lookup const specsMap = new Map( (specsResult.results as unknown as any[]).map(s => [s.spec_id, s]) ); // Step 4: Merge spec details into orders return orders.map(order => { const spec = specsMap.get(order.spec_id); return { ...order, vcpu: spec?.vcpu, memory_gb: spec?.memory_gb, disk_gb: spec?.disk_gb, bandwidth_tb: spec?.bandwidth_tb, spec_name: spec?.spec_name }; }); } /** * Find order by idempotency key (for duplicate prevention) */ async findOrderByIdempotencyKey(idempotencyKey: string): Promise { const result = await this.userDb .prepare('SELECT * FROM server_orders WHERE idempotency_key = ?') .bind(idempotencyKey) .first(); return result as unknown as ServerOrder | null; } // ============================================ // Pricing Lookup (cloud-instances-db) // Uses anvil_pricing, anvil_instances, anvil_regions // ============================================ async getPricingWithProvider(pricingId: number): Promise { const result = await this.db .prepare( `SELECT ap.id as pricing_id, 0 as provider_id, 'Anvil' as provider_name, CASE ar.source_provider WHEN 'linode' THEN 'https://api.linode.com/v4' WHEN 'vultr' THEN 'https://api.vultr.com/v2' ELSE 'https://api.anvil.cloud' END as api_base_url, it.instance_id as instance_id, ai.display_name as instance_name, ar.name as region_code, ar.display_name as region_name, ap.monthly_price, ai.vcpus as vcpu, CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb, ai.disk_gb as storage_gb, ar.source_provider, ar.source_region_code FROM anvil_pricing ap JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id JOIN anvil_regions ar ON ap.anvil_region_id = ar.id JOIN instance_types it ON ap.source_instance_id = it.id WHERE ap.id = ? AND ai.active = 1 AND ar.active = 1` ) .bind(pricingId) .first(); return result as unknown as PricingWithProvider | null; } // ============================================ // Atomic Operations (balance deduction + order creation) // ============================================ /** * Deduct balance and create order atomically * Returns order ID on success, null on insufficient balance */ async createOrderWithPayment( userId: number, telegramUserId: string, specId: number, region: string, priceKrw: number, label: string | null, image: string | null, idempotencyKey: string | null ): Promise<{ orderId: number | null; error?: string }> { try { // Step 1: Check and deduct balance const deducted = await this.deductBalance(userId, priceKrw); if (!deducted) { return { orderId: null, error: 'INSUFFICIENT_BALANCE' }; } // Step 2: Create order const order = await this.createServerOrder( userId, telegramUserId, specId, region, priceKrw, label, image, idempotencyKey ); return { orderId: order.id }; } catch (error) { // Attempt to refund on error try { await this.refundBalance(userId, priceKrw); } catch { // Log but don't throw - manual intervention needed console.error('[ProvisioningRepository] Failed to refund on error:', error); } return { orderId: null, error: 'ORDER_CREATION_FAILED' }; } } /** * Refund balance and update order status on provisioning failure */ async rollbackOrder(orderId: number, userId: number, amount: number, errorMessage: string): Promise { await this.refundBalance(userId, amount); await this.updateOrderStatus(orderId, 'failed', errorMessage); } // ============================================ // OS Image Operations (cloud-instances-db.os_images) // ============================================ /** * Get all active OS images */ async getActiveOsImages(): Promise { const result = await this.db .prepare( `SELECT * FROM os_images WHERE active = 1 ORDER BY sort_order` ) .all(); return result.results as unknown as OsImage[]; } /** * Get OS image by key */ async getOsImageByKey(key: string): Promise { const result = await this.db .prepare('SELECT * FROM os_images WHERE key = ? AND active = 1') .bind(key) .first(); return result as unknown as OsImage | null; } /** * Get default OS image */ async getDefaultOsImage(): Promise { const result = await this.db .prepare('SELECT * FROM os_images WHERE is_default = 1 AND active = 1') .first(); return result as unknown as OsImage | null; } /** * Validate OS image key exists and is active */ async isValidOsImage(key: string): Promise { const result = await this.db .prepare('SELECT 1 FROM os_images WHERE key = ? AND active = 1') .bind(key) .first(); return result !== null; } }