feat: complete phase one and two of task implementation

This commit is contained in:
Ayobami
2025-07-29 21:48:34 +01:00
parent b8fc924e7e
commit 42fec5708a
12 changed files with 1585 additions and 36 deletions
+33
View File
@@ -0,0 +1,33 @@
-- Get event statistics script
-- KEYS[1]: event metadata key (e.g., "event:1:meta")
-- KEYS[2]: event tickets key (e.g., "event:1:tickets")
local metaKey = KEYS[1]
local ticketKey = KEYS[2]
-- Check if event exists
if redis.call('EXISTS', metaKey) == 0 then
return nil
end
-- Get event metadata
local eventData = redis.call('HGETALL', metaKey)
local eventInfo = {}
for i = 1, #eventData, 2 do
eventInfo[eventData[i]] = eventData[i + 1]
end
-- Get remaining tickets count
local remainingTickets = redis.call('LLEN', ticketKey)
-- Return combined stats
return {
eventInfo.eventId,
eventInfo.name,
eventInfo.description,
eventInfo.totalTickets,
eventInfo.soldTickets,
remainingTickets,
eventInfo.createdAt,
eventInfo.lastSoldAt or "never"
}
+46
View File
@@ -0,0 +1,46 @@
-- Atomic ticket purchase script
-- KEYS[1]: event tickets key (e.g., "event:1:tickets")
-- KEYS[2]: event metadata key (e.g., "event:1:meta")
-- KEYS[3]: global stats key ("global:stats")
-- ARGV[1]: timestamp
-- ARGV[2]: purchase ID (UUID)
local ticketKey = KEYS[1]
local metaKey = KEYS[2]
local globalKey = KEYS[3]
local timestamp = ARGV[1]
local purchaseId = ARGV[2]
-- Check if event exists
if redis.call('EXISTS', metaKey) == 0 then
return {nil, "EVENT_NOT_FOUND"}
end
-- Atomically pop a ticket from the list
local ticket = redis.call('LPOP', ticketKey)
if not ticket then
return {nil, "NO_TICKETS_AVAILABLE"}
end
-- Update event metadata
local soldCount = redis.call('HINCRBY', metaKey, 'soldTickets', 1)
redis.call('HSET', metaKey, 'lastSoldAt', timestamp)
-- Update global stats
redis.call('HINCRBY', globalKey, 'totalSold', 1)
-- Store purchase record
local purchaseKey = 'purchase:' .. purchaseId
redis.call('HSET', purchaseKey, {
'ticketId', ticket,
'eventId', string.match(ticketKey, 'event:(%d+):tickets'),
'purchaseId', purchaseId,
'timestamp', timestamp,
'status', 'completed'
})
-- Set expiration for purchase record (24 hours)
redis.call('EXPIRE', purchaseKey, 86400)
return {ticket, "SUCCESS", soldCount}
+131
View File
@@ -0,0 +1,131 @@
const logger = require('./logger');
class FallbackStore {
constructor() {
this.events = new Map();
this.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null
};
this.isActive = false;
}
activate(reason) {
this.isActive = true;
logger.logFallback('In-Memory Store Activated', reason);
logger.warn('⚠️ FALLBACK MODE: Using in-memory store - data will not persist!');
}
deactivate() {
this.isActive = false;
logger.info('In-Memory Store Deactivated - Redis connection restored');
}
seedEvent(eventId, tickets, metadata) {
if (!this.isActive) return false;
this.events.set(eventId, {
tickets: [...tickets], // Create a copy
metadata: { ...metadata },
soldTickets: 0
});
this.globalStats.totalEvents++;
this.globalStats.totalTickets += tickets.length;
logger.info(`Fallback: Seeded event ${eventId} with ${tickets.length} tickets`);
return true;
}
purchaseTicket(eventId, purchaseId) {
if (!this.isActive) return null;
const event = this.events.get(eventId);
if (!event) {
return { success: false, error: 'EVENT_NOT_FOUND' };
}
if (event.tickets.length === 0) {
return { success: false, error: 'NO_TICKETS_AVAILABLE' };
}
// Atomically remove a ticket
const ticket = event.tickets.shift();
event.soldTickets++;
event.metadata.lastSoldAt = new Date().toISOString();
this.globalStats.totalSold++;
logger.logPurchase(eventId, ticket, purchaseId, true);
return {
success: true,
ticket,
soldCount: event.soldTickets
};
}
getEventStats(eventId) {
if (!this.isActive) return null;
const event = this.events.get(eventId);
if (!event) return null;
return {
eventId: eventId,
name: event.metadata.name,
description: event.metadata.description,
totalTickets: parseInt(event.metadata.totalTickets),
soldTickets: event.soldTickets,
remainingTickets: event.tickets.length,
createdAt: event.metadata.createdAt,
lastSoldAt: event.metadata.lastSoldAt || 'never'
};
}
getAllEvents() {
if (!this.isActive) return [];
const events = [];
for (const [eventId, _] of this.events) {
const stats = this.getEventStats(eventId);
if (stats) {
events.push(stats);
}
}
return events;
}
getGlobalStats() {
if (!this.isActive) return null;
return { ...this.globalStats };
}
isHealthy() {
return this.isActive;
}
clear() {
this.events.clear();
this.globalStats = {
totalEvents: 0,
totalTickets: 0,
totalSold: 0,
lastSeeded: null
};
logger.info('Fallback store cleared');
}
getStatus() {
return {
active: this.isActive,
eventsCount: this.events.size,
totalTickets: this.globalStats.totalTickets,
totalSold: this.globalStats.totalSold
};
}
}
module.exports = new FallbackStore();
+105
View File
@@ -0,0 +1,105 @@
const winston = require('winston');
const path = require('path');
// Create logs directory if it doesn't exist
const fs = require('fs');
const logsDir = path.join(process.cwd(), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Custom format for console output
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
let metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
return `${timestamp} [${level}]: ${message} ${metaStr}`;
})
);
// Custom format for file output
const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
);
// Create logger instance
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
defaultMeta: { service: 'ticket-microservice' },
transports: [
// Console transport
new winston.transports.Console({
format: consoleFormat
}),
// File transport for all logs
new winston.transports.File({
filename: path.join(logsDir, 'app.log'),
format: fileFormat,
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// File transport for errors only
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
format: fileFormat,
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
});
// Add request logging helper
logger.logRequest = (req, res, responseTime) => {
const logData = {
method: req.method,
url: req.url,
statusCode: res.statusCode,
responseTime: `${responseTime}ms`,
userAgent: req.get('User-Agent'),
ip: req.ip || req.connection.remoteAddress
};
if (res.statusCode >= 400) {
logger.warn('HTTP Request', logData);
} else {
logger.info('HTTP Request', logData);
}
};
// Add purchase logging helper
logger.logPurchase = (eventId, ticketId, purchaseId, success, error = null) => {
const logData = {
eventId,
ticketId,
purchaseId,
success,
timestamp: new Date().toISOString()
};
if (error) {
logData.error = error.message || error;
}
if (success) {
logger.info('Ticket Purchase', logData);
} else {
logger.error('Ticket Purchase Failed', logData);
}
};
// Add fallback logging helper
logger.logFallback = (operation, reason) => {
logger.warn('Fallback Activated', {
operation,
reason,
timestamp: new Date().toISOString()
});
};
module.exports = logger;
+247
View File
@@ -0,0 +1,247 @@
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
class PDFGenerator {
constructor() {
this.outputDir = process.env.PDF_OUTPUT_DIR || './tickets';
this.ensureOutputDirectory();
}
ensureOutputDirectory() {
if (!fs.existsSync(this.outputDir)) {
fs.mkdirSync(this.outputDir, { recursive: true });
logger.info(`Created PDF output directory: ${this.outputDir}`);
}
}
async generateTicketPDF(ticketData) {
return new Promise((resolve, reject) => {
try {
const {
ticketId,
eventId,
purchaseId,
eventName,
eventDescription,
timestamp,
soldCount
} = ticketData;
// Create PDF document
const doc = new PDFDocument({
size: 'A4',
margin: 50
});
// Generate filename
const filename = `ticket-${purchaseId}.pdf`;
const filepath = path.join(this.outputDir, filename);
// Pipe to file
const stream = fs.createWriteStream(filepath);
doc.pipe(stream);
// Header
doc.fontSize(24)
.fillColor('#2c3e50')
.text('🎫 TICKET RECEIPT', 50, 50, { align: 'center' });
// Divider line
doc.moveTo(50, 90)
.lineTo(545, 90)
.strokeColor('#3498db')
.lineWidth(2)
.stroke();
// Event Information
doc.fontSize(18)
.fillColor('#2c3e50')
.text('Event Information', 50, 120);
doc.fontSize(12)
.fillColor('#34495e')
.text(`Event Name: ${eventName || `Event ${eventId}`}`, 50, 150)
.text(`Event ID: ${eventId}`, 50, 170)
.text(`Description: ${eventDescription || 'No description available'}`, 50, 190);
// Ticket Information
doc.fontSize(18)
.fillColor('#2c3e50')
.text('Ticket Details', 50, 230);
doc.fontSize(12)
.fillColor('#34495e')
.text(`Ticket ID: ${ticketId}`, 50, 260)
.text(`Purchase ID: ${purchaseId}`, 50, 280)
.text(`Purchase Date: ${new Date(timestamp).toLocaleString()}`, 50, 300)
.text(`Ticket Number: #${soldCount}`, 50, 320);
// QR Code placeholder (you could integrate a QR code library here)
doc.rect(400, 250, 100, 100)
.strokeColor('#bdc3c7')
.lineWidth(1)
.stroke();
doc.fontSize(10)
.fillColor('#7f8c8d')
.text('QR Code', 430, 305, { align: 'center' });
// Terms and Conditions
doc.fontSize(14)
.fillColor('#2c3e50')
.text('Terms & Conditions', 50, 380);
doc.fontSize(10)
.fillColor('#7f8c8d')
.text('• This ticket is non-refundable and non-transferable', 50, 410)
.text('• Please arrive 30 minutes before the event starts', 50, 425)
.text('• Valid photo ID required for entry', 50, 440)
.text('• This ticket is valid only for the specified event and date', 50, 455);
// Footer
doc.fontSize(8)
.fillColor('#95a5a6')
.text(`Generated on ${new Date().toLocaleString()}`, 50, 520)
.text('Powered by Ticket Microservice', 50, 535)
.text(`System ID: ${process.env.NODE_ENV || 'development'}`, 50, 550);
// Security watermark
doc.fontSize(60)
.fillColor('#ecf0f1')
.text('VALID', 200, 300, {
rotate: -45,
opacity: 0.1
});
// Finalize PDF
doc.end();
stream.on('finish', () => {
logger.info(`PDF ticket generated: ${filename}`);
resolve({
success: true,
filename,
filepath,
size: fs.statSync(filepath).size
});
});
stream.on('error', (error) => {
logger.error('Error writing PDF file:', error);
reject(error);
});
} catch (error) {
logger.error('Error generating PDF:', error);
reject(error);
}
});
}
async generateBulkTicketsPDF(tickets) {
const results = [];
for (const ticket of tickets) {
try {
const result = await this.generateTicketPDF(ticket);
results.push(result);
} catch (error) {
logger.error(`Failed to generate PDF for ticket ${ticket.ticketId}:`, error);
results.push({
success: false,
ticketId: ticket.ticketId,
error: error.message
});
}
}
return results;
}
getTicketPath(purchaseId) {
return path.join(this.outputDir, `ticket-${purchaseId}.pdf`);
}
ticketExists(purchaseId) {
const filepath = this.getTicketPath(purchaseId);
return fs.existsSync(filepath);
}
async deleteTicket(purchaseId) {
try {
const filepath = this.getTicketPath(purchaseId);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
logger.info(`Deleted ticket PDF: ${purchaseId}`);
return true;
}
return false;
} catch (error) {
logger.error(`Error deleting ticket PDF ${purchaseId}:`, error);
throw error;
}
}
async cleanupOldTickets(maxAgeHours = 24) {
try {
const files = fs.readdirSync(this.outputDir);
const now = Date.now();
let deletedCount = 0;
for (const file of files) {
if (file.endsWith('.pdf')) {
const filepath = path.join(this.outputDir, file);
const stats = fs.statSync(filepath);
const ageHours = (now - stats.mtime.getTime()) / (1000 * 60 * 60);
if (ageHours > maxAgeHours) {
fs.unlinkSync(filepath);
deletedCount++;
}
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} old ticket PDFs`);
}
return deletedCount;
} catch (error) {
logger.error('Error cleaning up old tickets:', error);
throw error;
}
}
getStats() {
try {
const files = fs.readdirSync(this.outputDir);
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
let totalSize = 0;
for (const file of pdfFiles) {
const filepath = path.join(this.outputDir, file);
totalSize += fs.statSync(filepath).size;
}
return {
totalTickets: pdfFiles.length,
totalSizeBytes: totalSize,
totalSizeMB: (totalSize / (1024 * 1024)).toFixed(2),
outputDirectory: this.outputDir
};
} catch (error) {
logger.error('Error getting PDF stats:', error);
return {
totalTickets: 0,
totalSizeBytes: 0,
totalSizeMB: '0.00',
outputDirectory: this.outputDir,
error: error.message
};
}
}
}
module.exports = new PDFGenerator();
+185
View File
@@ -0,0 +1,185 @@
const redis = require('redis');
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
class RedisClient {
constructor() {
this.client = null;
this.isConnected = false;
this.luaScripts = {};
this.loadLuaScripts();
}
async connect() {
try {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
this.client = redis.createClient({ url: redisUrl });
this.client.on('error', (err) => {
logger.error('Redis Client Error:', err);
this.isConnected = false;
});
this.client.on('connect', () => {
logger.info('Redis client connected');
this.isConnected = true;
});
this.client.on('disconnect', () => {
logger.warn('Redis client disconnected');
this.isConnected = false;
});
await this.client.connect();
return this.client;
} catch (error) {
logger.error('Failed to connect to Redis:', error);
this.isConnected = false;
throw error;
}
}
loadLuaScripts() {
try {
const luaDir = path.join(__dirname, '../lua');
// Load purchase ticket script
const purchaseScript = fs.readFileSync(
path.join(luaDir, 'purchase-ticket.lua'),
'utf8'
);
this.luaScripts.purchaseTicket = purchaseScript;
// Load event stats script
const statsScript = fs.readFileSync(
path.join(luaDir, 'get-event-stats.lua'),
'utf8'
);
this.luaScripts.getEventStats = statsScript;
logger.info('Lua scripts loaded successfully');
} catch (error) {
logger.error('Failed to load Lua scripts:', error);
throw error;
}
}
async purchaseTicket(eventId, purchaseId, timestamp) {
if (!this.isConnected) {
throw new Error('Redis not connected');
}
const keys = [
`event:${eventId}:tickets`,
`event:${eventId}:meta`,
'global:stats'
];
const args = [timestamp, purchaseId];
try {
const result = await this.client.eval(
this.luaScripts.purchaseTicket,
{ keys, arguments: args }
);
return result;
} catch (error) {
logger.error('Error executing purchase ticket script:', error);
throw error;
}
}
async getEventStats(eventId) {
if (!this.isConnected) {
throw new Error('Redis not connected');
}
const keys = [
`event:${eventId}:meta`,
`event:${eventId}:tickets`
];
try {
const result = await this.client.eval(
this.luaScripts.getEventStats,
{ keys }
);
if (!result) return null;
return {
eventId: result[0],
name: result[1],
description: result[2],
totalTickets: parseInt(result[3]),
soldTickets: parseInt(result[4]),
remainingTickets: parseInt(result[5]),
createdAt: result[6],
lastSoldAt: result[7]
};
} catch (error) {
logger.error('Error executing get event stats script:', error);
throw error;
}
}
async getAllEvents() {
if (!this.isConnected) {
throw new Error('Redis not connected');
}
try {
const eventKeys = await this.client.keys('event:*:meta');
const events = [];
for (const key of eventKeys) {
const eventId = key.match(/event:(\d+):meta/)[1];
const stats = await this.getEventStats(eventId);
if (stats) {
events.push(stats);
}
}
return events;
} catch (error) {
logger.error('Error getting all events:', error);
throw error;
}
}
async getGlobalStats() {
if (!this.isConnected) {
throw new Error('Redis not connected');
}
try {
const stats = await this.client.hGetAll('global:stats');
return {
totalEvents: parseInt(stats.totalEvents) || 0,
totalTickets: parseInt(stats.totalTickets) || 0,
totalSold: parseInt(stats.totalSold) || 0,
lastSeeded: stats.lastSeeded || null
};
} catch (error) {
logger.error('Error getting global stats:', error);
throw error;
}
}
getClient() {
return this.client;
}
isHealthy() {
return this.isConnected && this.client && this.client.isReady;
}
async disconnect() {
if (this.client) {
await this.client.disconnect();
this.isConnected = false;
}
}
}
module.exports = new RedisClient();