โ ะะฐะทะฐะด/**
* Web Push Module โ real server-sent push notifications
* Each signal triggers immediate push to all subscribers
* Works even when browser is closed (via browser push service)
*/
const webpush = require('web-push')
let _stmts = null
let _enabled = false
function init({ stmts }) {
_stmts = stmts
const vapidPublic = process.env.VAPID_PUBLIC_KEY
const vapidPrivate = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@szhub.space'
if (!vapidPublic || !vapidPrivate) {
console.warn('[Push] VAPID keys not configured โ push disabled. Set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY env vars.')
return
}
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate)
_enabled = true
const count = _stmts.countPushSubs.get()?.count || 0
console.log(`[Push] Web Push initialized (${count} subscriptions)`)
}
/**
* Send notification with retry for transient network errors.
* Fire-and-forget: runs in background, never blocks caller.
* Retries: 2 attempts with 1s/3s backoff. Only retries network errors (no statusCode).
* 410/404 = expired subscription โ delete. Other 4xx = permanent, don't retry.
*/
async function sendWithRetry(pushSub, payload, endpoint, maxRetries = 2) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await webpush.sendNotification(pushSub, payload, { TTL: 300, timeout: 10000 })
try { _stmts.resetPushFail.run(endpoint) } catch {}
return // success
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
try { _stmts.deletePushSub.run(endpoint) } catch {}
console.log('[Push] Removed expired subscription')
return
}
// Network error (no statusCode) โ retry
const isTransient = !err.statusCode
if (isTransient && attempt < maxRetries) {
const delay = (attempt + 1) * 2000 // 2s, 4s
await new Promise(r => setTimeout(r, delay))
continue
}
// Give up
try { _stmts.incrementPushFail.run(endpoint) } catch {}
if (attempt === maxRetries && isTransient) {
console.warn(`[Push] Failed after ${maxRetries + 1} attempts: ${err.message}`)
} else {
console.error(`[Push] Send error (${err.statusCode}): ${err.message}`)
}
return
}
}
}
/**
* Send push notification for a signal โ called immediately from emitSignal()
* Fire-and-forget: never blocks signal emission
*/
function sendPushForSignal(signal) {
if (!_enabled || !_stmts) return
// Never push test signals
if (String(signal.id).startsWith('test-')) return
const subs = _stmts.getAllPushSubs.all()
if (!subs.length) return
const ticker = signal.symbol.replace('USDT', '')
const dir = signal.direction === 'LONG' ? 'โฒ LONG' : 'โผ SHORT'
const iconMap = { volume_spike: '๐', liq_sweep: '๐ฏ', oi_cvd: '๐ฎ', oi_divergence: '๐', oi_funding_squeeze: 'โก', channel: '๐' }
const icon = iconMap[signal.type] || '๐ฎ'
const confStr = `Conf ${signal.confidence}%`
const payload = JSON.stringify({
title: `${icon} ${ticker} ${dir}`,
body: `${confStr} โข ${signal.description || ''}`.slice(0, 200),
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: `signal-${signal.id}`,
data: { symbol: signal.symbol, signalId: signal.id, type: signal.type },
vibrate: [200, 100, 200],
})
let sent = 0
for (const sub of subs) {
// Server-side filtering per subscriber preferences
try {
const filters = JSON.parse(sub.filters || '{}')
if (filters.minConfidence && signal.confidence < filters.minConfidence) continue
if (filters.minRatio && signal.type === 'volume_spike' &&
(signal.metadata?.ratio || 0) < filters.minRatio) continue
if (filters.types?.length && !filters.types.includes(signal.type)) continue
if (filters.watchlistOnly && filters.watchlist?.length &&
!filters.watchlist.includes(signal.symbol)) continue
// Channel TF filter: skip if user disabled this timeframe
if (signal.type === 'channel' && filters.channelTimeframes?.length) {
const sigTf = signal.metadata?.interval
if (sigTf && !filters.channelTimeframes.includes(sigTf)) continue
}
} catch {}
const pushSub = {
endpoint: sub.endpoint,
keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth }
}
sent++
sendWithRetry(pushSub, payload, sub.endpoint)
}
if (sent > 0) {
console.log(`[Push] Signal ${signal.symbol} ${signal.type} โ sent to ${sent}/${subs.length} subscribers`)
}
}
function getVapidPublicKey() {
return process.env.VAPID_PUBLIC_KEY || null
}
function isEnabled() {
return _enabled
}
module.exports = { init, sendPushForSignal, getVapidPublicKey, isEnabled }