import { Request, Response } from 'express'; import { randomUUID } from 'crypto'; import { db } from '../database'; import { lnbitsService } from '../services/lnbits'; import { paymentMonitor } from '../services/paymentMonitor'; import { validateLightningAddress, validateTicketCount, sanitizeString } from '../utils/validation'; import config from '../config'; import { JackpotCycle, TicketPurchase, Ticket, Payout } from '../types'; import { AuthRequest } from '../middleware/auth'; /** * GET /status/maintenance * Public endpoint to check maintenance mode */ export async function getPublicMaintenanceStatus(req: Request, res: Response) { try { const result = await db.query( `SELECT value FROM system_settings WHERE key = 'maintenance_mode'` ); const pendingResult = await db.query( `SELECT value FROM system_settings WHERE key = 'maintenance_pending'` ); const messageResult = await db.query( `SELECT value FROM system_settings WHERE key = 'maintenance_message'` ); const enabled = result.rows[0]?.value === 'true'; const pending = pendingResult.rows[0]?.value === 'true'; const message = messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.'; return res.json({ version: '1.0', data: { maintenance_mode: enabled, maintenance_pending: pending, message: enabled ? message : (pending ? 'Maintenance will begin after the current draw completes.' : null), }, }); } catch (error: any) { // If table doesn't exist yet, return not in maintenance return res.json({ version: '1.0', data: { maintenance_mode: false, maintenance_pending: false, message: null, }, }); } } /** * Helper to check if system is in maintenance mode */ async function isMaintenanceMode(): Promise<{ enabled: boolean; message: string }> { try { const result = await db.query( `SELECT value FROM system_settings WHERE key = 'maintenance_mode'` ); const messageResult = await db.query( `SELECT value FROM system_settings WHERE key = 'maintenance_message'` ); return { enabled: result.rows[0]?.value === 'true', message: messageResult.rows[0]?.value || 'System is under maintenance. Please try again later.', }; } catch { return { enabled: false, message: '' }; } } const toIsoString = (value: any): string => { if (!value) { return new Date().toISOString(); } if (value instanceof Date) { return value.toISOString(); } if (typeof value === 'number') { return new Date(value).toISOString(); } if (typeof value === 'string') { // SQLite stores timestamps as "YYYY-MM-DD HH:mm:ss" by default const normalized = value.includes('T') ? value : value.replace(' ', 'T') + 'Z'; const parsed = new Date(normalized); if (!isNaN(parsed.getTime())) { return parsed.toISOString(); } } if (typeof value === 'object' && typeof (value as any).toISOString === 'function') { return (value as any).toISOString(); } const parsed = new Date(value); if (!isNaN(parsed.getTime())) { return parsed.toISOString(); } return new Date().toISOString(); }; /** * GET /jackpot/next * Returns the next upcoming cycle */ export async function getNextJackpot(req: Request, res: Response) { try { // Get active lottery const lotteryResult = await db.query( `SELECT * FROM lotteries WHERE status = 'active' LIMIT 1` ); if (lotteryResult.rows.length === 0) { return res.status(503).json({ version: '1.0', error: 'NO_ACTIVE_LOTTERY', message: 'No active lottery available', }); } const lottery = lotteryResult.rows[0]; // Get next cycle - first try to find one that hasn't drawn yet let cycleResult = await db.query( `SELECT * FROM jackpot_cycles WHERE lottery_id = $1 AND status IN ('scheduled', 'sales_open', 'drawing') ORDER BY scheduled_at ASC LIMIT 1`, [lottery.id] ); // If no active cycles, get the next upcoming one if (cycleResult.rows.length === 0) { cycleResult = await db.query( `SELECT * FROM jackpot_cycles WHERE lottery_id = $1 AND status = 'scheduled' AND scheduled_at > NOW() ORDER BY scheduled_at ASC LIMIT 1`, [lottery.id] ); } if (cycleResult.rows.length === 0) { return res.status(503).json({ version: '1.0', error: 'NO_UPCOMING_CYCLE', message: 'No upcoming cycle available', }); } const cycle = cycleResult.rows[0]; // Helper function to ensure ISO string format const toISOString = (date: any): string => { if (!date) return new Date().toISOString(); if (typeof date === 'string') return date.includes('Z') ? date : new Date(date).toISOString(); return date.toISOString(); }; return res.json({ version: '1.0', data: { lottery: { id: lottery.id, name: lottery.name, ticket_price_sats: parseInt(lottery.ticket_price_sats), }, cycle: { id: cycle.id, cycle_type: cycle.cycle_type, scheduled_at: toISOString(cycle.scheduled_at), sales_open_at: toISOString(cycle.sales_open_at), sales_close_at: toISOString(cycle.sales_close_at), status: cycle.status, pot_total_sats: parseInt(cycle.pot_total_sats.toString()), }, }, }); } catch (error: any) { console.error('Get next jackpot error:', error); return res.status(500).json({ version: '1.0', error: 'INTERNAL_ERROR', message: 'Failed to fetch next jackpot', }); } } /** * POST /jackpot/buy * Create a ticket purchase */ export async function buyTickets(req: AuthRequest, res: Response) { try { // Check maintenance mode first const maintenance = await isMaintenanceMode(); if (maintenance.enabled) { return res.status(503).json({ version: '1.0', error: 'MAINTENANCE_MODE', message: maintenance.message, }); } const { tickets, lightning_address, nostr_pubkey, name, buyer_name } = req.body; const userId = req.user?.id || null; const authNostrPubkey = req.user?.nostr_pubkey || null; // Validation if (!validateTicketCount(tickets, config.lottery.maxTicketsPerPurchase)) { return res.status(400).json({ version: '1.0', error: 'INVALID_TICKET_COUNT', message: `Tickets must be between 1 and ${config.lottery.maxTicketsPerPurchase}`, }); } if (!validateLightningAddress(lightning_address)) { return res.status(400).json({ version: '1.0', error: 'INVALID_LIGHTNING_ADDRESS', message: 'Lightning address must contain @ and be valid format', }); } const normalizedLightningAddress = sanitizeString(lightning_address); const rawNameInput = typeof name === 'string' ? name : typeof buyer_name === 'string' ? buyer_name : ''; const buyerName = sanitizeString(rawNameInput || 'Anon', 64) || 'Anon'; // Get active lottery const lotteryResult = await db.query( `SELECT * FROM lotteries WHERE status = 'active' LIMIT 1` ); if (lotteryResult.rows.length === 0) { return res.status(503).json({ version: '1.0', error: 'NO_ACTIVE_LOTTERY', message: 'No active lottery available', }); } const lottery = lotteryResult.rows[0]; // Get next available cycle const cycleResult = await db.query( `SELECT * FROM jackpot_cycles WHERE lottery_id = $1 AND status IN ('scheduled', 'sales_open') AND sales_close_at > NOW() ORDER BY scheduled_at ASC LIMIT 1`, [lottery.id] ); if (cycleResult.rows.length === 0) { return res.status(400).json({ version: '1.0', error: 'NO_AVAILABLE_CYCLE', message: 'No cycle available for ticket purchase', }); } const cycle = cycleResult.rows[0]; // Calculate amount const ticketPriceSats = parseInt(lottery.ticket_price_sats); const amountSats = tickets * ticketPriceSats; // Create ticket purchase record const purchaseId = randomUUID(); const purchaseResult = await db.query( `INSERT INTO ticket_purchases ( id, lottery_id, cycle_id, user_id, nostr_pubkey, lightning_address, buyer_name, number_of_tickets, ticket_price_sats, amount_sats, lnbits_invoice_id, lnbits_payment_hash, invoice_status, ticket_issue_status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, [ purchaseId, lottery.id, cycle.id, userId, nostr_pubkey || authNostrPubkey || null, normalizedLightningAddress, buyerName, tickets, ticketPriceSats, amountSats, '', // Will update after invoice creation '', // Will update after invoice creation 'pending', 'not_issued', ] ); const purchase = purchaseResult.rows[0]; const publicUrl = `${config.app.baseUrl}/tickets/${purchase.id}`; // Create invoice memo const memo = `Lightning Jackpot Tickets: ${tickets} Purchase ID: ${purchase.id} Check status: ${publicUrl}`; // Create LNbits invoice try { const webhookUrl = new URL('/webhooks/lnbits/payment', config.app.baseUrl); if (config.lnbits.webhookSecret) { webhookUrl.searchParams.set('secret', config.lnbits.webhookSecret); } const invoice = await lnbitsService.createInvoice({ amount: amountSats, memo: memo, webhook: webhookUrl.toString(), }); // Update purchase with invoice details await db.query( `UPDATE ticket_purchases SET lnbits_invoice_id = $1, lnbits_payment_hash = $2, updated_at = NOW() WHERE id = $3`, [invoice.checking_id, invoice.payment_hash, purchase.id] ); if (userId) { await db.query( `UPDATE users SET lightning_address = $1, updated_at = NOW() WHERE id = $2`, [normalizedLightningAddress, userId] ); } paymentMonitor.addInvoice(purchase.id, invoice.payment_hash); return res.json({ version: '1.0', data: { ticket_purchase_id: purchase.id, public_url: publicUrl, invoice: { payment_request: invoice.payment_request, amount_sats: amountSats, }, }, }); } catch (invoiceError: any) { // Cleanup purchase if invoice creation fails await db.query('DELETE FROM ticket_purchases WHERE id = $1', [purchase.id]); return res.status(502).json({ version: '1.0', error: 'INVOICE_CREATION_FAILED', message: 'Failed to create Lightning invoice', }); } } catch (error: any) { console.error('Buy tickets error:', error); return res.status(500).json({ version: '1.0', error: 'INTERNAL_ERROR', message: 'Failed to process ticket purchase', }); } } /** * GET /tickets/:id * Get ticket purchase status */ export async function getTicketStatus(req: Request, res: Response) { try { const { id } = req.params; // Get purchase const purchaseResult = await db.query( `SELECT * FROM ticket_purchases WHERE id = $1`, [id] ); if (purchaseResult.rows.length === 0) { return res.status(404).json({ version: '1.0', error: 'PURCHASE_NOT_FOUND', message: 'Ticket purchase not found', }); } const purchase = purchaseResult.rows[0]; // Get cycle const cycleResult = await db.query( `SELECT * FROM jackpot_cycles WHERE id = $1`, [purchase.cycle_id] ); const cycle = cycleResult.rows[0]; // Get tickets const ticketsResult = await db.query( `SELECT * FROM tickets WHERE ticket_purchase_id = $1 ORDER BY serial_number`, [id] ); const tickets = ticketsResult.rows.map(t => ({ id: t.id, serial_number: parseInt(t.serial_number.toString()), is_winning_ticket: cycle.winning_ticket_id === t.id, })); // Determine result let result = { has_drawn: cycle.status === 'completed', is_winner: false, payout: null as any, }; if (cycle.status === 'completed' && cycle.winning_ticket_id) { const isWinner = tickets.some(t => t.id === cycle.winning_ticket_id); result.is_winner = isWinner; if (isWinner) { // Get payout const payoutResult = await db.query( `SELECT * FROM payouts WHERE ticket_id = $1`, [cycle.winning_ticket_id] ); if (payoutResult.rows.length > 0) { const payout = payoutResult.rows[0]; result.payout = { status: payout.status, amount_sats: parseInt(payout.amount_sats.toString()), }; } } } return res.json({ version: '1.0', data: { purchase: { id: purchase.id, lottery_id: purchase.lottery_id, cycle_id: purchase.cycle_id, lightning_address: purchase.lightning_address, buyer_name: purchase.buyer_name, number_of_tickets: purchase.number_of_tickets, ticket_price_sats: parseInt(purchase.ticket_price_sats.toString()), amount_sats: parseInt(purchase.amount_sats.toString()), invoice_status: purchase.invoice_status, ticket_issue_status: purchase.ticket_issue_status, created_at: toIsoString(purchase.created_at), }, tickets, cycle: { id: cycle.id, cycle_type: cycle.cycle_type, scheduled_at: toIsoString(cycle.scheduled_at), sales_open_at: toIsoString(cycle.sales_open_at), sales_close_at: toIsoString(cycle.sales_close_at), status: cycle.status, pot_total_sats: parseInt(cycle.pot_total_sats.toString()), pot_after_fee_sats: cycle.pot_after_fee_sats ? parseInt(cycle.pot_after_fee_sats.toString()) : null, winning_ticket_id: cycle.winning_ticket_id, }, result, }, }); } catch (error: any) { console.error('Get ticket status error:', error); return res.status(500).json({ version: '1.0', error: 'INTERNAL_ERROR', message: 'Failed to fetch ticket status', }); } } interface PastWinRow { cycle_id: string; cycle_type: JackpotCycle['cycle_type']; scheduled_at: Date | string; pot_total_sats: number; pot_after_fee_sats: number | null; buyer_name: string | null; serial_number: number | null; winning_lightning_address: string | null; } /** * Truncate lightning address for privacy * "username@blink.sv" -> "us******@blink.sv" */ function truncateLightningAddress(address: string | null): string | null { if (!address || !address.includes('@')) return address; const [username, domain] = address.split('@'); // Show first 2 chars of username, then asterisks const visibleChars = Math.min(2, username.length); const truncatedUsername = username.substring(0, visibleChars) + '******'; return `${truncatedUsername}@${domain}`; } /** * GET /jackpot/past-wins * List past jackpots with winner info */ export async function getPastWins(req: Request, res: Response) { try { const limitParam = parseInt((req.query.limit as string) || '20', 10); const offsetParam = parseInt((req.query.offset as string) || '0', 10); const limit = Math.min(100, Math.max(1, Number.isNaN(limitParam) ? 20 : limitParam)); const offset = Math.max(0, Number.isNaN(offsetParam) ? 0 : offsetParam); const result = await db.query( `SELECT jc.id as cycle_id, jc.cycle_type, jc.scheduled_at, jc.pot_total_sats, jc.pot_after_fee_sats, jc.winning_lightning_address, tp.buyer_name, t.serial_number FROM jackpot_cycles jc JOIN tickets t ON jc.winning_ticket_id = t.id JOIN ticket_purchases tp ON t.ticket_purchase_id = tp.id WHERE jc.status = 'completed' AND jc.winning_ticket_id IS NOT NULL ORDER BY jc.scheduled_at DESC LIMIT $1 OFFSET $2`, [limit, offset] ); const wins = result.rows.map((row) => ({ cycle_id: row.cycle_id, cycle_type: row.cycle_type, scheduled_at: toIsoString(row.scheduled_at), pot_total_sats: parseInt(row.pot_total_sats.toString()), pot_after_fee_sats: row.pot_after_fee_sats ? parseInt(row.pot_after_fee_sats.toString()) : null, winner_name: row.buyer_name || 'Anon', winner_address: truncateLightningAddress(row.winning_lightning_address), winning_ticket_serial: row.serial_number ? parseInt(row.serial_number.toString()) : null, })); return res.json({ version: '1.0', data: { wins, }, }); } catch (error: any) { console.error('Get past wins error:', error); return res.status(500).json({ version: '1.0', error: 'INTERNAL_ERROR', message: 'Failed to load past wins', }); } }