Refactor monolithic modules and harden booking, email, and auth infrastructure.

Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-25 07:12:59 +00:00
co-authored by Cursor
parent f0e2de2834
commit 613bd7be1d
75 changed files with 7702 additions and 5580 deletions
+71 -8
View File
@@ -5,15 +5,26 @@ import { eq, and } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { getPubSub } from '../lib/stores/pubsub.js';
import { getLock } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
const channelUnsubs = new Map<string, () => void>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/** Pub/sub channel that carries payment events for a ticket. */
function paymentChannel(ticketId: string): string {
return `payment:${ticketId}`;
}
/**
* LNbits webhook payload structure
*/
@@ -32,9 +43,21 @@ interface LNbitsWebhookPayload {
}
/**
* Notify all connected clients for a ticket
* Notify every client for a ticket across all instances.
*
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
* mode this is an in-process broadcast; with Redis it reaches whichever
* instance(s) actually hold the SSE socket(s) for this ticket.
*/
async function notifyClients(ticketId: string, data: any) {
await getPubSub().publish(paymentChannel(ticketId), data);
}
/**
* Deliver an event to the SSE sockets held by THIS process for a ticket.
* Invoked by the pub/sub subscription handler.
*/
async function deliverLocal(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
await Promise.all(
@@ -49,17 +72,42 @@ async function notifyClients(ticketId: string, data: any) {
}
}
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
const checkerLockTokens = new Map<string, string>();
/** Release the per-ticket poller lock if this process holds it. */
function releaseCheckerLock(ticketId: string) {
const token = checkerLockTokens.get(ticketId);
if (token) {
checkerLockTokens.delete(ticketId);
void getLock().release(`checker:${ticketId}`, token);
}
}
/**
* Start background payment checking for a ticket
* Start background payment checking for a ticket.
*
* Only one instance should poll LNbits per ticket, so we take a distributed
* lock for the lifetime of the poll. Other instances skip polling and instead
* receive the result via pub/sub. With no Redis configured the lock is a local
* no-op and behavior matches the original single-instance polling.
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking on this instance
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
if (!lockToken) {
// Another instance is already polling this ticket.
return;
}
checkerLockTokens.set(ticketId, lockToken);
const startTime = Date.now();
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
@@ -73,6 +121,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
@@ -84,6 +133,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
@@ -104,6 +154,7 @@ function stopBackgroundChecker(ticketId: string) {
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
}
}
@@ -273,7 +324,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
// Start background checker if not already running (only while still pending)
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
@@ -291,9 +342,15 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
return;
}
// Register this connection
// Register this connection. The first local connection for a ticket also
// subscribes to the ticket's pub/sub channel so events published by any
// instance (webhook or background checker) are delivered to these sockets.
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
void deliverLocal(ticketId, data);
});
channelUnsubs.set(ticketId, unsub);
}
activeConnections.get(ticketId)!.add(sendEvent);
@@ -317,6 +374,12 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
// Drop the pub/sub subscription once no local sockets remain.
const unsub = channelUnsubs.get(ticketId);
if (unsub) {
unsub();
channelUnsubs.delete(ticketId);
}
}
}
});