feat: Add configurable draw cycles, improve UX

Backend:
- Add configurable draw cycle settings (minutes/hourly/daily/weekly/custom)
- Add CYCLE_TYPE, CYCLE_INTERVAL_*, CYCLE_DAILY_TIME, CYCLE_WEEKLY_* env vars
- Add SALES_CLOSE_BEFORE_DRAW_MINUTES and CYCLES_TO_GENERATE_AHEAD
- Fix SQLite parameter issue in scheduler

Frontend:
- Add 'Save This Link' section with copy button on ticket status page
- Improve draw animation to show immediately when draw starts
- Show 'Waiting for next round...' instead of 'Drawing Now!' after draw
- Hide Buy Tickets button when waiting for next round
- Skip draw animation if no tickets were sold
- Keep winner screen open longer (15s) for next cycle to load
- Auto-refresh to next lottery cycle after draw

Telegram Bot:
- Various improvements and fixes
This commit is contained in:
Michilis
2025-11-28 03:24:17 +00:00
parent f743a6749c
commit 918d3bc31e
21 changed files with 584 additions and 140 deletions

View File

@@ -42,6 +42,20 @@ const allowedCorsOrigins = (() => {
return Array.from(new Set([...frontendOrigins, appBaseUrl]));
})();
type CycleType = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'custom';
interface CycleConfig {
type: CycleType;
intervalMinutes: number;
intervalHours: number;
dailyTime: string;
weeklyDay: number;
weeklyTime: string;
cronExpression: string;
salesCloseBeforeDrawMinutes: number;
cyclesToGenerateAhead: number;
}
interface Config {
app: {
port: number;
@@ -75,6 +89,7 @@ interface Config {
defaultHouseFeePercent: number;
maxTicketsPerPurchase: number;
};
cycle: CycleConfig;
admin: {
apiKey: string;
};
@@ -119,6 +134,17 @@ const config: Config = {
defaultHouseFeePercent: parseInt(process.env.DEFAULT_HOUSE_FEE_PERCENT || '5', 10),
maxTicketsPerPurchase: 100,
},
cycle: {
type: (process.env.CYCLE_TYPE || 'hourly') as CycleType,
intervalMinutes: parseInt(process.env.CYCLE_INTERVAL_MINUTES || '60', 10),
intervalHours: parseInt(process.env.CYCLE_INTERVAL_HOURS || '1', 10),
dailyTime: process.env.CYCLE_DAILY_TIME || '18:00',
weeklyDay: parseInt(process.env.CYCLE_WEEKLY_DAY || '6', 10),
weeklyTime: process.env.CYCLE_WEEKLY_TIME || '20:00',
cronExpression: process.env.CYCLE_CRON_EXPRESSION || '0 * * * *',
salesCloseBeforeDrawMinutes: parseInt(process.env.SALES_CLOSE_BEFORE_DRAW_MINUTES || '5', 10),
cyclesToGenerateAhead: parseInt(process.env.CYCLES_TO_GENERATE_AHEAD || '5', 10),
},
admin: {
apiKey: process.env.ADMIN_API_KEY || '',
},
@@ -149,6 +175,51 @@ function validateConfig(): void {
console.error('❌ DATABASE_TYPE must be either "postgres" or "sqlite"');
process.exit(1);
}
// Validate cycle type
const validCycleTypes: CycleType[] = ['minutes', 'hourly', 'daily', 'weekly', 'custom'];
if (!validCycleTypes.includes(config.cycle.type)) {
console.error(`❌ CYCLE_TYPE must be one of: ${validCycleTypes.join(', ')}`);
process.exit(1);
}
// Validate weekly day (0-6)
if (config.cycle.weeklyDay < 0 || config.cycle.weeklyDay > 6) {
console.error('❌ CYCLE_WEEKLY_DAY must be between 0 (Sunday) and 6 (Saturday)');
process.exit(1);
}
// Validate time formats (HH:MM)
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(config.cycle.dailyTime)) {
console.error('❌ CYCLE_DAILY_TIME must be in HH:MM format (24-hour)');
process.exit(1);
}
if (!timeRegex.test(config.cycle.weeklyTime)) {
console.error('❌ CYCLE_WEEKLY_TIME must be in HH:MM format (24-hour)');
process.exit(1);
}
// Log cycle configuration
console.log(`✓ Cycle configuration: ${config.cycle.type}`);
switch (config.cycle.type) {
case 'minutes':
console.log(` → Draw every ${config.cycle.intervalMinutes} minutes`);
break;
case 'hourly':
console.log(` → Draw every ${config.cycle.intervalHours} hour(s)`);
break;
case 'daily':
console.log(` → Draw daily at ${config.cycle.dailyTime} UTC`);
break;
case 'weekly':
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
console.log(` → Draw every ${days[config.cycle.weeklyDay]} at ${config.cycle.weeklyTime} UTC`);
break;
case 'custom':
console.log(` → Cron: ${config.cycle.cronExpression}`);
break;
}
}
if (config.app.nodeEnv !== 'test') {

View File

@@ -4,9 +4,120 @@ import { executeDraw } from '../services/draw';
import { autoRetryFailedPayouts } from '../services/payout';
import config from '../config';
import { JackpotCycle } from '../types';
import { randomUUID } from 'crypto';
/**
* Generate future cycles for all cycle types
* Calculate the next scheduled draw time based on cycle configuration
*/
function calculateNextDrawTime(fromDate: Date): Date {
const cycleConfig = config.cycle;
let next = new Date(fromDate);
switch (cycleConfig.type) {
case 'minutes':
// Add interval minutes
next = new Date(next.getTime() + cycleConfig.intervalMinutes * 60 * 1000);
break;
case 'hourly':
// Add interval hours
next = new Date(next.getTime() + cycleConfig.intervalHours * 60 * 60 * 1000);
// Round to the start of the hour
next.setMinutes(0, 0, 0);
break;
case 'daily':
// Move to next day at specified time
const [dailyHour, dailyMinute] = cycleConfig.dailyTime.split(':').map(Number);
next.setUTCDate(next.getUTCDate() + 1);
next.setUTCHours(dailyHour, dailyMinute, 0, 0);
break;
case 'weekly':
// Find next occurrence of the specified day
const [weeklyHour, weeklyMinute] = cycleConfig.weeklyTime.split(':').map(Number);
const targetDay = cycleConfig.weeklyDay;
const currentDay = next.getUTCDay();
// Calculate days until target day
let daysUntilTarget = targetDay - currentDay;
if (daysUntilTarget <= 0) {
daysUntilTarget += 7;
}
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
next.setUTCHours(weeklyHour, weeklyMinute, 0, 0);
break;
case 'custom':
// Parse cron and calculate next occurrence
next = calculateNextCronTime(cycleConfig.cronExpression, next);
break;
}
return next;
}
/**
* Calculate next occurrence from a cron expression
*/
function calculateNextCronTime(cronExpr: string, fromDate: Date): Date {
// Simple cron parser for common patterns
// Format: "minute hour day-of-month month day-of-week"
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) {
console.error('Invalid cron expression:', cronExpr);
// Fallback to 1 hour from now
return new Date(fromDate.getTime() + 60 * 60 * 1000);
}
const [minutePart, hourPart] = parts;
let next = new Date(fromDate);
// Handle common patterns
if (hourPart.startsWith('*/')) {
// Every X hours
const interval = parseInt(hourPart.slice(2), 10);
next = new Date(next.getTime() + interval * 60 * 60 * 1000);
next.setMinutes(parseInt(minutePart, 10) || 0, 0, 0);
} else if (minutePart.startsWith('*/')) {
// Every X minutes
const interval = parseInt(minutePart.slice(2), 10);
next = new Date(next.getTime() + interval * 60 * 1000);
} else {
// Specific time - move to next occurrence
const targetHour = hourPart === '*' ? next.getUTCHours() : parseInt(hourPart, 10);
const targetMinute = minutePart === '*' ? 0 : parseInt(minutePart, 10);
next.setUTCHours(targetHour, targetMinute, 0, 0);
if (next <= fromDate) {
next.setUTCDate(next.getUTCDate() + 1);
}
}
return next;
}
/**
* Get the cycle type name for database storage based on config
*/
function getCycleTypeName(): 'hourly' | 'daily' | 'weekly' | 'monthly' {
switch (config.cycle.type) {
case 'minutes':
case 'hourly':
case 'custom':
return 'hourly';
case 'daily':
return 'daily';
case 'weekly':
return 'weekly';
default:
return 'hourly';
}
}
/**
* Generate future cycles based on configuration
*/
async function generateFutureCycles(): Promise<void> {
try {
@@ -23,36 +134,34 @@ async function generateFutureCycles(): Promise<void> {
}
const lottery = lotteryResult.rows[0];
const cycleTypes: Array<'hourly' | 'daily' | 'weekly' | 'monthly'> = ['hourly', 'daily'];
const cycleType = getCycleTypeName();
const cyclesToGenerate = config.cycle.cyclesToGenerateAhead;
const salesCloseMinutes = config.cycle.salesCloseBeforeDrawMinutes;
for (const cycleType of cycleTypes) {
await generateCyclesForType(lottery.id, cycleType);
// Get existing future cycles count
const existingResult = await db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM jackpot_cycles
WHERE lottery_id = $1
AND status IN ('scheduled', 'sales_open')
AND scheduled_at > NOW()`,
[lottery.id]
);
const existingCount = parseInt(existingResult.rows[0]?.count || '0', 10);
const cyclesToCreate = Math.max(0, cyclesToGenerate - existingCount);
if (cyclesToCreate === 0) {
console.log(`Already have ${existingCount} future cycles, no generation needed`);
return;
}
} catch (error) {
console.error('Cycle generation error:', error);
}
}
/**
* Generate cycles for a specific type
*/
async function generateCyclesForType(
lotteryId: string,
cycleType: 'hourly' | 'daily' | 'weekly' | 'monthly'
): Promise<void> {
try {
// Determine horizon (how far in the future to generate)
const horizonHours = cycleType === 'hourly' ? 48 : 168; // 48h for hourly, 1 week for daily
const horizonDate = new Date(Date.now() + horizonHours * 60 * 60 * 1000);
// Get latest cycle for this type
// Get the latest cycle to determine next sequence number and time
const latestResult = await db.query<JackpotCycle>(
`SELECT * FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2
ORDER BY sequence_number DESC
WHERE lottery_id = $1
ORDER BY scheduled_at DESC
LIMIT 1`,
[lotteryId, cycleType]
[lottery.id]
);
let lastScheduledAt: Date;
@@ -68,71 +177,44 @@ async function generateCyclesForType(
sequenceNumber = latest.sequence_number;
}
// Generate cycles until horizon
while (lastScheduledAt < horizonDate) {
// Generate new cycles
for (let i = 0; i < cyclesToCreate; i++) {
sequenceNumber++;
// Calculate next scheduled time
let nextScheduledAt: Date;
const nextScheduledAt = calculateNextDrawTime(lastScheduledAt);
switch (cycleType) {
case 'hourly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 60 * 60 * 1000);
break;
case 'daily':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 24 * 60 * 60 * 1000);
nextScheduledAt.setHours(20, 0, 0, 0); // 8 PM UTC
break;
case 'weekly':
nextScheduledAt = new Date(lastScheduledAt.getTime() + 7 * 24 * 60 * 60 * 1000);
break;
case 'monthly':
nextScheduledAt = new Date(lastScheduledAt);
nextScheduledAt.setMonth(nextScheduledAt.getMonth() + 1);
break;
}
// Sales open immediately, close at draw time
// Sales open now, close X minutes before draw
const salesOpenAt = new Date();
const salesCloseAt = nextScheduledAt;
const salesCloseAt = new Date(nextScheduledAt.getTime() - salesCloseMinutes * 60 * 1000);
// Check if cycle already exists
const existingResult = await db.query(
`SELECT id FROM jackpot_cycles
WHERE lottery_id = $1 AND cycle_type = $2 AND sequence_number = $3`,
[lotteryId, cycleType, sequenceNumber]
const cycleId = randomUUID();
await db.query(
`INSERT INTO jackpot_cycles (
id, lottery_id, cycle_type, sequence_number, scheduled_at,
sales_open_at, sales_close_at, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
cycleId,
lottery.id,
cycleType,
sequenceNumber,
nextScheduledAt.toISOString(),
salesOpenAt.toISOString(),
salesCloseAt.toISOString(),
'scheduled',
]
);
if (existingResult.rows.length === 0) {
// Create new cycle with explicit UUID generation
const crypto = require('crypto');
const cycleId = crypto.randomUUID();
await db.query(
`INSERT INTO jackpot_cycles (
id, lottery_id, cycle_type, sequence_number, scheduled_at,
sales_open_at, sales_close_at, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
cycleId,
lotteryId,
cycleType,
sequenceNumber,
nextScheduledAt.toISOString(),
salesOpenAt.toISOString(),
salesCloseAt.toISOString(),
'scheduled',
]
);
console.log(`Created ${cycleType} cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
}
console.log(`Created cycle #${sequenceNumber} (${cycleId}) for ${nextScheduledAt.toISOString()}`);
lastScheduledAt = nextScheduledAt;
}
console.log(`Generated ${cyclesToCreate} new cycles`);
} catch (error) {
console.error(`Error generating ${cycleType} cycles:`, error);
console.error('Cycle generation error:', error);
}
}
@@ -167,23 +249,53 @@ async function checkAndExecuteDraws(): Promise<void> {
}
}
/**
* Update cycles to 'sales_open' status when appropriate
*/
async function updateCycleStatuses(): Promise<void> {
try {
const now = new Date().toISOString();
// Update scheduled cycles to sales_open when sales period starts
await db.query(
`UPDATE jackpot_cycles
SET status = 'sales_open', updated_at = NOW()
WHERE status = 'scheduled'
AND sales_open_at <= $1
AND scheduled_at > $2`,
[now, now]
);
} catch (error) {
console.error('Cycle status update error:', error);
}
}
/**
* Start all schedulers
*/
export function startSchedulers(): void {
console.log('Starting schedulers...');
// Log cycle configuration
console.log(`Cycle type: ${config.cycle.type}`);
// Cycle generator - every 5 minutes (or configured interval)
const cycleGenInterval = Math.max(config.scheduler.cycleGeneratorIntervalSeconds, 60);
cron.schedule(`*/${Math.floor(cycleGenInterval / 60)} * * * *`, generateFutureCycles);
const cycleGenMinutes = Math.max(1, Math.floor(cycleGenInterval / 60));
cron.schedule(`*/${cycleGenMinutes} * * * *`, generateFutureCycles);
console.log(`✓ Cycle generator scheduled (every ${cycleGenInterval}s)`);
console.log(`✓ Cycle generator scheduled (every ${cycleGenMinutes} minute(s))`);
// Draw executor - every minute (or configured interval)
const drawInterval = Math.max(config.scheduler.drawIntervalSeconds, 30);
cron.schedule(`*/${Math.floor(drawInterval / 60)} * * * *`, checkAndExecuteDraws);
const drawMinutes = Math.max(1, Math.floor(drawInterval / 60));
cron.schedule(`*/${drawMinutes} * * * *`, async () => {
await updateCycleStatuses();
await checkAndExecuteDraws();
});
console.log(`✓ Draw executor scheduled (every ${drawInterval}s)`);
console.log(`✓ Draw executor scheduled (every ${drawMinutes} minute(s))`);
// Payout retry - every 10 minutes
cron.schedule('*/10 * * * *', autoRetryFailedPayouts);
@@ -191,9 +303,9 @@ export function startSchedulers(): void {
console.log(`✓ Payout retry scheduled (every 10 minutes)`);
// Run immediately on startup
setTimeout(() => {
generateFutureCycles();
checkAndExecuteDraws();
setTimeout(async () => {
await generateFutureCycles();
await updateCycleStatuses();
await checkAndExecuteDraws();
}, 5000);
}