All files / src/utils roadmap-notify.js

100% Statements 26/26
100% Branches 18/18
100% Functions 2/2
100% Lines 21/21

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                        3x 3x 3x               19x       19x         18x           14x 14x 20x 20x       20x 18x 18x   15x                                   14x   10x 15x 10x 10x   1x           3x  
/**
 * Roadmap update notification trigger.
 *
 * Queries all subscribers who opted into roadmapUpdate notifications,
 * then dispatches notifications INLINE per subscriber via
 * `dispatchNotificationInline`. No persistent queue, no cron.
 *
 * Fire-and-forget at the route layer (callers in routes/suggestions.js
 * already use `.catch(...)` and don't await this function), so the
 * admin HTTP response returns immediately while dispatch fans out.
 */
 
const { db } = require('./firebase');
const { dispatchNotificationInline } = require('./notification-channels');
const log = require('./log');
 
/**
 * Notify all roadmapUpdate subscribers about a roadmap change.
 *
 * @param {string} message - Description of what changed (shown to users)
 */
async function notifyRoadmapSubscribers(message) {
  try {
    // Server-side filter on the denormalised `roadmapUpdateOptedIn` flag.
    // A full-collection scan would cost a read per subscriber every
    // roadmap edit; the flag is maintained on every PUT /subscriptions/me.
    const snap = await db
      .collection('subscriptions')
      .where('roadmapUpdateOptedIn', '==', true)
      .get();
 
    if (snap.empty) return;
 
    // Dispatch in parallel per subscriber. Each dispatch is wrapped in
    // its own try/catch inside `dispatchNotificationInline`, so a
    // per-subscriber failure is contained — Promise.allSettled prevents
    // one rejected promise from cancelling the whole fan-out.
    const dispatches = [];
    for (const doc of snap.docs) {
      const sub = doc.data();
      const prefs = sub.channelPreferences?.roadmapUpdate;
 
      // Defensive double-check: if the denormalised flag drifted from
      // the actual prefs (race between PUT and notify), trust the prefs.
      if (!prefs) continue;
      const hasAnyChannel = prefs.email || prefs.push || prefs.inApp || prefs.systemMessage;
      if (!hasAnyChannel) continue;
 
      dispatches.push(
        dispatchNotificationInline({
          type: 'roadmapUpdate',
          uid: sub.uid || doc.id,
          title: 'Roadmap Update',
          body: message,
          channels: {
            email: !!prefs.email,
            push: !!prefs.push,
            inApp: !!prefs.inApp,
            systemMessage: !!prefs.systemMessage,
          },
          email: sub.email || null,
          pushToken: sub.pushToken || null,
        }),
      );
    }
 
    if (dispatches.length === 0) return;
 
    const settled = await Promise.allSettled(dispatches);
    const sent = settled.filter((r) => r.status === 'fulfilled').length;
    const failed = settled.length - sent;
    log.info('roadmap-notify', `Dispatched ${sent} roadmap notifications inline (${failed} threw)`);
  } catch (err) {
    log.error('roadmap-notify', 'Failed to notify roadmap subscribers', {
      error: err.message,
    });
  }
}
 
module.exports = { notifyRoadmapSubscribers };