All files / src/utils age-verification-fcm.js

100% Statements 26/26
91.66% Branches 11/12
100% Functions 6/6
100% Lines 24/24

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                                                1x 1x 1x     11x 11x 10x 10x             11x 11x 11x 1x       1x   10x 9x 8x         2x 1x               8x   1x           1x           6x                       2x                             3x             1x          
/**
 * FCM push handlers for the three age-verification decision outcomes
 * (PR 10/14). Sends a data-only push to the user's stored fcmTokens
 * so the Android service can render a local notification while the
 * app is backgrounded — the system PM (PR 5) handles the in-app side
 * when the app is open.
 *
 * Best-effort by design:
 *   - No-op when the user has no fcmTokens (logged-out / no push
 *     permission) — these users will see the system PM next launch.
 *   - Sends are awaited at the call site but errors are swallowed
 *     into a structured log; the admin decision must NOT fail just
 *     because a push couldn't go through. The admin-age-verification
 *     route has a partial-failure flag (`pushNotified`) for ops.
 *
 * Wire format mirrors the existing PM push (data-only, all values
 * stringified). Type strings are the dispatch key the Android
 * `ShyTalkMessagingService` switches on:
 *   - AGE_VERIF_APPROVED
 *   - AGE_VERIF_REJECTED
 *   - AGE_VERIF_DOB_MODIFIED  (modifiedToVerified flag tells the
 *     handler whether to show the approve-style or reject-style copy)
 */
 
const { db } = require('./firebase');
const { sendFcmToTokens, cleanupInvalidTokens } = require('./fcm');
const log = require('./log');
 
async function loadFcmTokens(targetUserId) {
  const snap = await db.doc(`users/${targetUserId}`).get();
  if (!snap.exists) return { tokens: [], userExists: false };
  const tokens = snap.data().fcmTokens;
  return {
    tokens: Array.isArray(tokens) ? tokens : [],
    userExists: true,
  };
}
 
async function sendOutcomePush(targetUserId, data) {
  try {
    const { tokens, userExists } = await loadFcmTokens(targetUserId);
    if (!userExists) {
      log.warn('age-verification-fcm', 'Target user missing — skipping push', {
        targetUserId,
        type: data.type,
      });
      return false;
    }
    if (tokens.length === 0) return true; // not a failure — no devices
    const invalid = await sendFcmToTokens(tokens, data);
    if (invalid.length > 0) {
      // Best-effort cleanup of stale tokens — pruning failures here
      // shouldn't block the decision flow, but they MUST surface in
      // logs so ops can spot a Firestore-permission regression that
      // would otherwise let stale tokens accumulate forever.
      cleanupInvalidTokens(invalid, targetUserId).catch((cleanupErr) => {
        log.warn('age-verification-fcm', 'Stale-token cleanup failed', {
          targetUserId,
          invalidCount: invalid.length,
          error: cleanupErr?.message,
          code: cleanupErr?.code,
        });
      });
    }
    return true;
  } catch (err) {
    log.error('age-verification-fcm', 'push send failed', {
      targetUserId,
      type: data.type,
      error: err?.message,
      code: err?.code,
    });
    return false;
  }
}
 
/** Push for the Approve decision — the user is now 18+ verified. */
async function sendAgeVerificationApprovedPush(targetUserId) {
  return sendOutcomePush(targetUserId, {
    type: 'AGE_VERIF_APPROVED',
    targetUserId: String(targetUserId),
  });
}
 
/**
 * Push for the Reject decision. `reason` is the admin's user-facing
 * justification that's already in the system PM — we forward a short
 * preview so the lock-screen notification has substance.
 */
async function sendAgeVerificationRejectedPush(targetUserId, reason) {
  return sendOutcomePush(targetUserId, {
    type: 'AGE_VERIF_REJECTED',
    targetUserId: String(targetUserId),
    // Cap the reason at 80 chars for the lock-screen preview. The full
    // text is in the system PM body.
    reasonPreview: typeof reason === 'string' ? reason.slice(0, 80) : '',
  });
}
 
/**
 * Push for the Modify-DOB decision. `becameVerified` true means the
 * new DOB makes the user 18+ — handler renders approve-style copy.
 * Otherwise renders reject-style "still locked" copy.
 */
async function sendAgeVerificationDobModifiedPush(targetUserId, becameVerified) {
  return sendOutcomePush(targetUserId, {
    type: 'AGE_VERIF_DOB_MODIFIED',
    targetUserId: String(targetUserId),
    becameVerified: String(Boolean(becameVerified)),
  });
}
 
module.exports = {
  sendAgeVerificationApprovedPush,
  sendAgeVerificationRejectedPush,
  sendAgeVerificationDobModifiedPush,
};