All files / src/utils notification-channels.js

100% Statements 34/34
100% Branches 34/34
100% Functions 1/1
100% Lines 34/34

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                            2x 2x 2x 2x 2x 2x                                     24x 24x 24x   24x 6x 6x 4x   2x         2x       24x 7x 7x           6x     6x 2x 2x   1x               1x         1x       24x 6x 6x 5x   1x         1x       24x             24x     2x  
/**
 * Inline per-channel notification dispatch.
 *
 * Extracted from the deleted `cron/notification-dispatch.js` so the
 * queue-write + cron-poll mechanism is gone — callers dispatch
 * directly. Per-channel try/catch isolates failures: a stale FCM
 * token doesn't block email, an SMTP timeout doesn't block system PM.
 *
 * The function returns a per-channel result object (sent / failed /
 * skipped) for grep-able structured logging at the caller. It never
 * throws to the caller for delivery failures — fire-and-forget at the
 * route layer is the expected pattern.
 */
 
const crypto = require('node:crypto');
const { db } = require('./firebase');
const { sendEmail } = require('./email');
const { sendFcmToTokens } = require('./fcm');
const { sendSystemPm } = require('./system-pm');
const log = require('./log');
 
/**
 * Dispatch a single notification to its configured channels.
 *
 * @param {object} notif
 * @param {object} [notif.channels] - { email?, push?, systemMessage?, inApp? }
 * @param {string} [notif.uid]
 * @param {string} [notif.type]
 * @param {string} [notif.title]
 * @param {string} [notif.body]
 * @param {string|null} [notif.email]
 * @param {string|null} [notif.pushToken]
 * @param {string} [notif.relatedId]
 * @returns {Promise<{email: string|null, push: string|null, systemMessage: string|null}>}
 *   Per-channel result: 'sent', 'failed', or null when the channel was
 *   not requested / no recipient address was present.
 */
async function dispatchNotificationInline(notif) {
  const { channels, uid, type, title, body, email, pushToken, relatedId } = notif || {};
  const correlationId = `notif-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
  const results = { email: null, push: null, systemMessage: null };
 
  if (channels?.email && email) {
    try {
      await sendEmail(email, title || 'ShyTalk Notification', `<p>${body || ''}</p>`);
      results.email = 'sent';
    } catch (err) {
      log.error('notification-channels', 'Email send failed', {
        correlationId,
        uid,
        error: err.message,
      });
      results.email = 'failed';
    }
  }
 
  if (channels?.push && pushToken) {
    try {
      const invalidTokens = await sendFcmToTokens([pushToken], {
        type: type || 'notification',
        title: title || '',
        body: body || '',
        relatedId: relatedId || '',
      });
      results.push = 'sent';
      // Clean up tokens FCM rejected. Best-effort — stale tokens that
      // linger here mean future sends keep failing for this user.
      if (invalidTokens && invalidTokens.length > 0 && uid) {
        try {
          await db.doc(`subscriptions/${uid}`).update({ pushToken: null });
        } catch (cleanupErr) {
          log.warn('notification-channels', 'Failed to clear invalid pushToken (best-effort)', {
            correlationId,
            uid,
            error: cleanupErr.message,
          });
        }
      }
    } catch (err) {
      log.error('notification-channels', 'FCM send failed', {
        correlationId,
        uid,
        error: err.message,
      });
      results.push = 'failed';
    }
  }
 
  if (channels?.systemMessage && uid) {
    try {
      await sendSystemPm(String(uid), body || title || 'You have a new notification');
      results.systemMessage = 'sent';
    } catch (err) {
      log.error('notification-channels', 'System PM failed', {
        correlationId,
        uid,
        error: err.message,
      });
      results.systemMessage = 'failed';
    }
  }
 
  log.info('notification-channels', 'Notification dispatched', {
    correlationId,
    uid,
    type,
    results,
  });
 
  return results;
}
 
module.exports = { dispatchNotificationInline };