From 404fdf26103492ead75b3bea931bccdf0dc7b3de Mon Sep 17 00:00:00 2001 From: Michilis Date: Tue, 9 Dec 2025 00:46:55 +0000 Subject: [PATCH] Maintenance mode activates after current draw completes - When admin enables maintenance, it's set to 'pending' state - Maintenance activates automatically after the current draw completes - Admin can use immediate=true to force immediate activation - Frontend shows 'Maintenance Scheduled' banner when pending - Telegram bot warns users but still allows purchases when pending - Both mode and pending status tracked in system_settings table --- back_end/src/controllers/admin.ts | 119 +++++++++++++++++++++++++++++ back_end/src/controllers/public.ts | 72 +++++++++++++++++ back_end/src/database/index.ts | 11 +++ back_end/src/routes/admin.ts | 77 ++++++++++++++++++- back_end/src/routes/public.ts | 30 +++++++- back_end/src/scheduler/index.ts | 29 +++++++ front_end/src/app/buy/page.tsx | 31 ++++++++ front_end/src/app/page.tsx | 49 +++++++++++- front_end/src/lib/api.ts | 10 +++ telegram_bot/src/handlers/buy.ts | 20 +++++ telegram_bot/src/messages/index.ts | 11 +++ telegram_bot/src/services/api.ts | 19 +++++ 12 files changed, 474 insertions(+), 4 deletions(-) diff --git a/back_end/src/controllers/admin.ts b/back_end/src/controllers/admin.ts index cf6e4ce..7e1762e 100644 --- a/back_end/src/controllers/admin.ts +++ b/back_end/src/controllers/admin.ts @@ -189,3 +189,122 @@ export async function retryPayout(req: Request, res: Response) { } } +/** + * GET /admin/maintenance + * Get current maintenance mode status + */ +export async function getMaintenanceStatus(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: message, + }, + }); + } catch (error: any) { + console.error('Get maintenance status error:', error); + return res.status(500).json({ + version: '1.0', + error: 'INTERNAL_ERROR', + message: 'Failed to get maintenance status', + }); + } +} + +/** + * POST /admin/maintenance + * Enable or disable maintenance mode + * When enabling, maintenance is set to "pending" and activates after current draw completes + */ +export async function setMaintenanceMode(req: Request, res: Response) { + try { + const { enabled, message, immediate } = req.body; + + if (typeof enabled !== 'boolean') { + return res.status(400).json({ + version: '1.0', + error: 'INVALID_INPUT', + message: 'enabled must be a boolean', + }); + } + + if (enabled) { + if (immediate === true) { + // Immediate activation (admin override) + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'true', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')` + ); + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')` + ); + console.log('Maintenance mode ENABLED IMMEDIATELY'); + } else { + // Set pending - will activate after current draw completes + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'true', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')` + ); + console.log('Maintenance mode PENDING (will activate after current draw)'); + } + } else { + // Disable both active and pending maintenance + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'false', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')` + ); + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')` + ); + console.log('Maintenance mode DISABLED'); + } + + // Update message if provided + if (message && typeof message === 'string') { + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_message', $1, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = $1, updated_at = datetime('now')`, + [message] + ); + } + + // Get current state + const modeResult = 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'`); + + return res.json({ + version: '1.0', + data: { + maintenance_mode: modeResult.rows[0]?.value === 'true', + maintenance_pending: pendingResult.rows[0]?.value === 'true', + message: message || 'System is under maintenance. Please try again later.', + }, + }); + } catch (error: any) { + console.error('Set maintenance mode error:', error); + return res.status(500).json({ + version: '1.0', + error: 'INTERNAL_ERROR', + message: 'Failed to set maintenance mode', + }); + } +} + diff --git a/back_end/src/controllers/public.ts b/back_end/src/controllers/public.ts index e50f213..573cfc3 100644 --- a/back_end/src/controllers/public.ts +++ b/back_end/src/controllers/public.ts @@ -8,6 +8,68 @@ 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(); @@ -140,6 +202,16 @@ export async function getNextJackpot(req: Request, res: Response) { */ 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; diff --git a/back_end/src/database/index.ts b/back_end/src/database/index.ts index c7b6cd3..ae4c2f6 100644 --- a/back_end/src/database/index.ts +++ b/back_end/src/database/index.ts @@ -158,6 +158,17 @@ class DatabaseWrapper { created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Initialize default settings + INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_mode', 'false'); + INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_pending', 'false'); + INSERT OR IGNORE INTO system_settings (key, value) VALUES ('maintenance_message', 'System is under maintenance. Please try again later.'); + -- Create indexes CREATE INDEX IF NOT EXISTS idx_cycles_status_time ON jackpot_cycles(status, scheduled_at); CREATE INDEX IF NOT EXISTS idx_ticketpurchase_paymenthash ON ticket_purchases(lnbits_payment_hash); diff --git a/back_end/src/routes/admin.ts b/back_end/src/routes/admin.ts index d1f1cea..0f59f64 100644 --- a/back_end/src/routes/admin.ts +++ b/back_end/src/routes/admin.ts @@ -3,7 +3,9 @@ import { listCycles, runDrawManually, retryPayout, - listPayouts + listPayouts, + getMaintenanceStatus, + setMaintenanceMode } from '../controllers/admin'; import { verifyAdmin } from '../middleware/auth'; @@ -126,5 +128,78 @@ router.get('/payouts', listPayouts); */ router.post('/payouts/:id/retry', retryPayout); +/** + * @swagger + * /admin/maintenance: + * get: + * summary: Get maintenance mode status + * tags: [Admin] + * security: + * - adminKey: [] + * responses: + * 200: + * description: Maintenance status + * content: + * application/json: + * schema: + * type: object + * properties: + * maintenance_mode: + * type: boolean + * message: + * type: string + * 403: + * description: Invalid admin key + */ +router.get('/maintenance', getMaintenanceStatus); + +/** + * @swagger + * /admin/maintenance: + * post: + * summary: Enable or disable maintenance mode + * description: When enabling, maintenance is set to "pending" and activates after current draw completes. Use immediate=true to activate immediately. + * tags: [Admin] + * security: + * - adminKey: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - enabled + * properties: + * enabled: + * type: boolean + * description: Whether to enable maintenance mode + * message: + * type: string + * description: Custom maintenance message + * immediate: + * type: boolean + * description: If true, activate immediately instead of waiting for draw to complete + * responses: + * 200: + * description: Maintenance mode updated + * content: + * application/json: + * schema: + * type: object + * properties: + * maintenance_mode: + * type: boolean + * maintenance_pending: + * type: boolean + * message: + * type: string + * 400: + * description: Invalid input + * 403: + * description: Invalid admin key + */ +router.post('/maintenance', setMaintenanceMode); + export default router; diff --git a/back_end/src/routes/public.ts b/back_end/src/routes/public.ts index 9cb4777..18ebbef 100644 --- a/back_end/src/routes/public.ts +++ b/back_end/src/routes/public.ts @@ -3,7 +3,8 @@ import { getNextJackpot, buyTickets, getTicketStatus, - getPastWins + getPastWins, + getPublicMaintenanceStatus } from '../controllers/public'; import { buyRateLimiter, ticketStatusRateLimiter } from '../middleware/rateLimit'; import { optionalAuth } from '../middleware/auth'; @@ -187,5 +188,32 @@ router.get('/jackpot/past-wins', getPastWins); */ router.get('/tickets/:id', ticketStatusRateLimiter, getTicketStatus); +/** + * @swagger + * /status/maintenance: + * get: + * summary: Check if system is in maintenance mode + * tags: [Public] + * responses: + * 200: + * description: Maintenance status + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * type: string + * data: + * type: object + * properties: + * maintenance_mode: + * type: boolean + * message: + * type: string + * nullable: true + */ +router.get('/status/maintenance', getPublicMaintenanceStatus); + export default router; diff --git a/back_end/src/scheduler/index.ts b/back_end/src/scheduler/index.ts index 224a108..0957dce 100644 --- a/back_end/src/scheduler/index.ts +++ b/back_end/src/scheduler/index.ts @@ -244,6 +244,8 @@ async function checkAndExecuteDraws(): Promise { await executeDraw(cycle.id); // Cancel unpaid invoices for this cycle after draw await cancelUnpaidPurchases(cycle.id); + // Check if maintenance was pending and activate it + await activatePendingMaintenance(); } } catch (error) { @@ -275,6 +277,33 @@ async function cancelUnpaidPurchases(cycleId: string): Promise { } } +/** + * Check if maintenance is pending and activate it after draw completion + */ +async function activatePendingMaintenance(): Promise { + try { + const pendingResult = await db.query( + `SELECT value FROM system_settings WHERE key = 'maintenance_pending'` + ); + + if (pendingResult.rows[0]?.value === 'true') { + // Activate maintenance mode + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_mode', 'true', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'true', updated_at = datetime('now')` + ); + // Clear pending flag + await db.query( + `INSERT INTO system_settings (key, value, updated_at) VALUES ('maintenance_pending', 'false', datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = 'false', updated_at = datetime('now')` + ); + console.log('🔧 MAINTENANCE MODE ACTIVATED (pending maintenance enabled after draw)'); + } + } catch (error) { + console.error('Error activating pending maintenance:', error); + } +} + /** * Update cycles to 'sales_open' status when appropriate */ diff --git a/front_end/src/app/buy/page.tsx b/front_end/src/app/buy/page.tsx index bb3cfa0..34f13ca 100644 --- a/front_end/src/app/buy/page.tsx +++ b/front_end/src/app/buy/page.tsx @@ -17,6 +17,8 @@ export default function BuyPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [jackpot, setJackpot] = useState(null); + const [maintenanceMode, setMaintenanceMode] = useState(false); + const [maintenanceMessage, setMaintenanceMessage] = useState(null); // Form state const [lightningAddress, setLightningAddress] = useState(''); @@ -125,6 +127,11 @@ export default function BuyPage() { const loadJackpot = async () => { try { + // Check maintenance status first + const maintenanceStatus = await api.getMaintenanceStatus(); + setMaintenanceMode(maintenanceStatus.maintenance_mode); + setMaintenanceMessage(maintenanceStatus.message); + const response = await api.getNextJackpot(); if (response.data) { setJackpot(response.data); @@ -221,6 +228,30 @@ export default function BuyPage() { const ticketPriceSats = jackpot.lottery.ticket_price_sats; const totalCost = ticketPriceSats * tickets; + // Show maintenance message if in maintenance mode + if (maintenanceMode) { + return ( +
+

+ {STRINGS.buy.title} +

+
+ 🔧 +

Maintenance Mode

+

+ {maintenanceMessage || 'System is under maintenance. Please try again later.'} +

+ +
+
+ ); + } + return (

diff --git a/front_end/src/app/page.tsx b/front_end/src/app/page.tsx index ccce82a..5f47244 100644 --- a/front_end/src/app/page.tsx +++ b/front_end/src/app/page.tsx @@ -33,6 +33,20 @@ export default function HomePage() { const [isRecentWin, setIsRecentWin] = useState(false); const [awaitingNextCycle, setAwaitingNextCycle] = useState(false); const [pendingWinner, setPendingWinner] = useState(null); + const [maintenanceMode, setMaintenanceMode] = useState(false); + const [maintenancePending, setMaintenancePending] = useState(false); + const [maintenanceMessage, setMaintenanceMessage] = useState(null); + + const loadMaintenanceStatus = useCallback(async () => { + try { + const status = await api.getMaintenanceStatus(); + setMaintenanceMode(status.maintenance_mode); + setMaintenancePending(status.maintenance_pending); + setMaintenanceMessage(status.message); + } catch { + // Ignore errors, assume not in maintenance + } + }, []); const loadJackpot = useCallback(async () => { try { @@ -76,9 +90,10 @@ export default function HomePage() { }, [drawJustCompleted]); useEffect(() => { + loadMaintenanceStatus(); loadJackpot(); loadRecentWinner(); - }, [loadJackpot, loadRecentWinner]); + }, [loadMaintenanceStatus, loadJackpot, loadRecentWinner]); // Detect when draw time passes and trigger draw animation (only if tickets were sold) useEffect(() => { @@ -245,6 +260,36 @@ export default function HomePage() { /> )} + {/* Maintenance Mode Banner */} + {maintenanceMode && ( +
+
+ 🔧 +
+

Maintenance Mode

+

+ {maintenanceMessage || 'System is under maintenance. Please try again later.'} +

+
+
+
+ )} + + {/* Pending Maintenance Banner */} + {!maintenanceMode && maintenancePending && ( +
+
+ +
+

Maintenance Scheduled

+

+ Maintenance will begin after the current draw completes. Last chance to buy tickets! +

+
+
+
+ )} + {/* Hero Section */}

@@ -313,7 +358,7 @@ export default function HomePage() {

{/* Buy Button - Hide when waiting for next round */} - {!isWaitingForNextRound && ( + {!isWaitingForNextRound && !maintenanceMode && (
{ + try { + const response = await this.request<{ data: { maintenance_mode: boolean; maintenance_pending: boolean; message: string | null } }>('/status/maintenance'); + return response.data; + } catch { + // If endpoint doesn't exist or fails, assume not in maintenance + return { maintenance_mode: false, maintenance_pending: false, message: null }; + } + } + // Auth endpoints async nostrAuth(nostrPubkey: string, signedMessage: string, nonce: string) { return this.request('/auth/nostr', { diff --git a/telegram_bot/src/handlers/buy.ts b/telegram_bot/src/handlers/buy.ts index 65fe659..87bafd8 100644 --- a/telegram_bot/src/handlers/buy.ts +++ b/telegram_bot/src/handlers/buy.ts @@ -33,6 +33,26 @@ export async function handleBuyCommand( logUserAction(userId, 'Initiated ticket purchase'); try { + // Check maintenance mode first + const maintenance = await apiClient.checkMaintenanceStatus(); + if (maintenance.enabled) { + await bot.sendMessage( + chatId, + messages.errors.maintenance(maintenance.message || 'System is under maintenance.'), + { parse_mode: 'Markdown' } + ); + return; + } + + // Warn if maintenance is pending (but still allow purchase) + if (maintenance.pending) { + await bot.sendMessage( + chatId, + messages.errors.maintenancePending, + { parse_mode: 'Markdown' } + ); + } + const user = await stateManager.getUser(userId); if (!user) { diff --git a/telegram_bot/src/messages/index.ts b/telegram_bot/src/messages/index.ts index aa6b5a8..c869cfb 100644 --- a/telegram_bot/src/messages/index.ts +++ b/telegram_bot/src/messages/index.ts @@ -20,6 +20,17 @@ export const messages = { checkStatusFailed: '❌ Failed to check status', noPendingPurchase: '❌ No pending purchase. Please start again with /buyticket', setAddressFirst: '❌ Please set your Lightning Address first.', + maintenance: (message: string) => `🔧 *Maintenance Mode* + +${message} + +Please try again later. We'll be back soon! ⚡`, + + maintenancePending: `⏳ *Maintenance Scheduled* + +Maintenance will begin after the current draw completes. + +This is your *last chance* to buy tickets for the current round! 🎟️`, }, // ═══════════════════════════════════════════════════════════════════════════ diff --git a/telegram_bot/src/services/api.ts b/telegram_bot/src/services/api.ts index 30b6067..467eb98 100644 --- a/telegram_bot/src/services/api.ts +++ b/telegram_bot/src/services/api.ts @@ -138,6 +138,25 @@ class ApiClient { return false; } } + + /** + * Check if system is in maintenance mode + */ + async checkMaintenanceStatus(): Promise<{ enabled: boolean; pending: boolean; message: string | null }> { + try { + const response = await this.client.get>( + '/status/maintenance' + ); + return { + enabled: response.data.data.maintenance_mode, + pending: response.data.data.maintenance_pending, + message: response.data.data.message, + }; + } catch (error) { + // If endpoint doesn't exist or fails, assume not in maintenance + return { enabled: false, pending: false, message: null }; + } + } } export const apiClient = new ApiClient();