import { notificationService, PushNotificationPayload } from './notification.service'; import { storage } from '../storage'; const PUSHY_API_URL = 'https://api.pushy.me/push/json'; // Known Pushy error messages that indicate a device token is permanently invalid. // Only these trigger token cleanup; transient errors (rate-limit, 400 malformed, etc.) are left alone. const INVALID_DEVICE_ERROR_PATTERNS = [ 'devicenotregistered', 'invalid push token', 'device not found', 'device not registered', ]; interface PushyErrorResponse { error?: string; code?: number; status?: number; } export class PushService { isConfigured(): boolean { return !!process.env.PUSHY_API_KEY; } async sendNotification(payload: PushNotificationPayload): Promise<{ success: number; failure: number }> { const apiKey = process.env.PUSHY_API_KEY; if (!apiKey) { console.log('Push notification skipped: PUSHY_API_KEY not set'); return { success: 0, failure: payload.tokens.length }; } if (payload.tokens.length === 0) { return { success: 0, failure: 0 }; } // Build data payload, always including a deep-link URL when taskId + formId are present const data: Record = { ...(payload.data || {}), organizationId: payload.organizationId.toString(), userId: payload.userId.toString(), }; // Only build the deep-link URL if the upstream caller has not already provided one. // Include ?tab=chat when the notification originates from a chat event (isChat flag). if (!data.url && data.taskId && data.formId) { const chatSuffix = data.isChat === 'true' ? '?tab=chat' : ''; data.url = `/forms/${data.formId}/tasks/${data.taskId}${chatSuffix}`; } let success = 0; let failure = 0; const invalidTokens: string[] = []; for (const token of payload.tokens) { try { const response = await fetch(`${PUSHY_API_URL}?api_key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to: token, data, notification: { title: payload.title, body: payload.body, sound: 'default', badge: 1, }, }), }); if (response.ok) { success++; } else { let errorBody: PushyErrorResponse = {}; try { errorBody = await response.json() as PushyErrorResponse; } catch { // ignore parse errors } console.error(`Pushy push failed for token: ${errorBody.error ?? response.status}`); if (errorBody.error && INVALID_DEVICE_ERROR_PATTERNS.some( p => errorBody.error!.toLowerCase().includes(p) )) { invalidTokens.push(token); } failure++; } } catch (error) { console.error('Pushy request error:', error); failure++; } } console.log(`Pushy push sent to user ${payload.userId} in org ${payload.organizationId}: ${success} success, ${failure} failed`); if (invalidTokens.length > 0) { const deletedCount = await storage.deleteInvalidDeviceTokens(invalidTokens); console.log(`Cleaned up ${deletedCount} invalid device tokens`); } return { success, failure }; } async processPushQueue(): Promise<{ processed: number; success: number; failure: number }> { const pending = notificationService.getPendingPushNotifications(); if (pending.length === 0) { return { processed: 0, success: 0, failure: 0 }; } let totalSuccess = 0; let totalFailure = 0; for (const p of pending) { const result = await this.sendNotification(p); totalSuccess += result.success; totalFailure += result.failure; } console.log(`Push queue processed: ${pending.length} notifications, ${totalSuccess} success, ${totalFailure} failed`); return { processed: pending.length, success: totalSuccess, failure: totalFailure }; } startQueueProcessor(intervalMs: number = 5000): NodeJS.Timeout { console.log(`Starting push queue processor with ${intervalMs}ms interval`); return setInterval(async () => { if (notificationService.hasPendingPushNotifications()) { await this.processPushQueue(); } }, intervalMs); } } export const pushService = new PushService();