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:
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user