All files / src/cron notification-dispatch.js

88.88% Statements 40/45
76.92% Branches 30/39
100% Functions 1/1
90.69% Lines 39/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141                        2x 2x 2x 2x 2x   2x     11x           11x   11x 11x   11x 112x     112x   111x 111x     111x 106x 106x 106x 106x   1x                 111x 109x 109x               109x 2x 2x                                             111x 105x 105x                         111x 110x   1x       1x 1x                             1x       11x 10x       2x  
/**
 * Notification dispatch cron job.
 *
 * Processes queued notifications in batch:
 * - Email via Postfix (nodemailer)
 * - Push via FCM
 * - System messages via sendSystemPm
 * - In-app notifications via Firestore
 *
 * Handles failures gracefully with retry queue.
 */
 
const { db } = require('../utils/firebase');
const { sendEmail } = require('../utils/email');
const { sendFcmToTokens } = require('../utils/fcm');
const { sendSystemPm } = require('../utils/system-pm');
const log = require('../utils/log');
 
const MAX_BATCH_SIZE = 50;
 
async function dispatchNotifications() {
  const snap = await db
    .collection('notificationQueue')
    .where('status', '==', 'queued')
    .limit(MAX_BATCH_SIZE)
    .get();
 
  Iif (snap.empty) return;
 
  let sent = 0;
  let failed = 0;
 
  for (const doc of snap.docs) {
    const notif = doc.data();
 
    // Skip already-sent (idempotent)
    if (notif.status === 'sent') continue;
 
    try {
      const { channels } = notif;
 
      // Email
      if (channels?.email && notif.email) {
        try {
          const subject = notif.title || 'ShyTalk Notification';
          const html = `<p>${notif.body || ''}</p>`;
          await sendEmail(notif.email, subject, html);
        } catch (err) {
          log.error('notification-dispatch', 'Email send failed', {
            notifId: doc.id,
            error: err.message,
          });
          // Queue for retry — don't fail the whole notification
        }
      }
 
      // Push (FCM)
      if (channels?.push && notif.pushToken) {
        try {
          const invalidTokens = await sendFcmToTokens([notif.pushToken], {
            type: notif.type || 'notification',
            title: notif.title || '',
            body: notif.body || '',
            relatedId: notif.relatedId || '',
          });
 
          // Clean up invalid tokens
          if (invalidTokens && invalidTokens.length > 0) {
            try {
              await db.doc(`subscriptions/${notif.uid}`).update({
                pushToken: null,
              });
            } catch (cleanupErr) {
              // Don't fail the notification dispatch over a stale-token
              // write failure, but log it: stale tokens that linger here
              // mean future sends keep failing for that user (silent decay).
              log.warn('notification-dispatch', 'Failed to clear invalid pushToken (best-effort)', {
                uid: notif.uid,
                notifId: doc.id,
                error: cleanupErr.message,
              });
            }
          }
        } catch (err) {
          log.error('notification-dispatch', 'FCM send failed', {
            notifId: doc.id,
            error: err.message,
          });
        }
      }
 
      // System message
      if (channels?.systemMessage && notif.uid) {
        try {
          await sendSystemPm(
            String(notif.uid),
            notif.body || notif.title || 'You have a new notification',
          );
        } catch (err) {
          log.error('notification-dispatch', 'System PM failed', {
            notifId: doc.id,
            error: err.message,
          });
        }
      }
 
      // Mark as sent
      await doc.ref.update({ status: 'sent', sentAt: Date.now() });
      sent++;
    } catch (err) {
      log.error('notification-dispatch', 'Notification dispatch failed', {
        notifId: doc.id,
        error: err.message,
      });
      try {
        await doc.ref.update({ status: 'failed', error: err.message });
      } catch (statusErr) {
        // If we can't mark `failed`, the queue item stays `queued` and gets
        // re-dispatched on every cron tick — potentially re-spamming the
        // user AND burning Firestore quota. Log so this is visible in
        // production, even though we can't auto-recover.
        log.error(
          'notification-dispatch',
          'Failed to mark notification as failed (will re-attempt next tick)',
          {
            notifId: doc.id,
            error: statusErr.message,
          },
        );
      }
      failed++;
    }
  }
 
  if (sent > 0 || failed > 0) {
    log.info('notification-dispatch', `Dispatched ${sent} notifications, ${failed} failed`);
  }
}
 
module.exports = dispatchNotifications;