All files / src/cron index.js

87.27% Statements 48/55
100% Branches 2/2
63.15% Functions 12/19
92.3% Lines 48/52

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 142 143 144 1451x 1x   1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     18x         18x 2x 2x       16x 2x 2x 1x         16x 2x 2x 1x   2x     2x                                 16x 1x 1x 1x       16x 3x 3x           16x 1x 1x                                   16x 1x 1x                                                 16x 4x 4x 2x 2x                 1x         16x     1x  
const cron = require('node-cron');
const log = require('../utils/log');
 
const archiveReports = require('./archiveReports');
const subscriptions = require('./subscriptions');
const backpackCleanup = require('./backpackCleanup');
const backups = require('./backups');
const closedRooms = require('./closedRooms');
const orphanedStorage = require('./orphanedStorage');
const rotateLogs = require('./rotateLogs');
const expireTempIds = require('./expireTempIds');
const expireDataExports = require('./expireDataExports');
const alertManager = require('../utils/alertManagerInstance');
const ageVerificationAuditReconcile = require('./ageVerificationAuditReconcile');
 
function startCronJobs() {
  const isProd = process.env.NODE_ENV === 'production';
 
  // All cron jobs are prod-only. Dev testing happens against local emulators
  // which have no quota limits, so cron cleanup is unnecessary. Running cron
  // on dev was burning ~4k Firestore deletes/day for no benefit.
  if (!isProd) {
    log.info('cron', 'Cron jobs disabled (non-production environment)');
    return;
  }
 
  // Archive old reports — Sunday 03:00 UTC
  cron.schedule('0 3 * * 0', () => {
    log.info('cron', 'Running archiveReports');
    archiveReports().catch((err) =>
      log.error('cron', 'archiveReports failed', { error: err.message }),
    );
  });
 
  // Check expired subscriptions + clean expired backpack items + expire temp IDs — daily midnight UTC
  cron.schedule('0 0 * * *', () => {
    log.info('cron', 'Running subscriptions + backpackCleanup + expireTempIds');
    subscriptions().catch((err) =>
      log.error('cron', 'subscriptions failed', { error: err.message }),
    );
    backpackCleanup().catch((err) =>
      log.error('cron', 'backpackCleanup failed', { error: err.message }),
    );
    expireTempIds().catch((err) =>
      log.error('cron', 'expireTempIds failed', { error: err.message }),
    );
  });
 
  // staleRooms ELIMINATED — voice rooms are now closed event-driven via
  // the RTDB `ownerLeft/{roomId}` signal. The owner client arms an
  // onDisconnect at room entry (Android + iOS, see PresenceService.
  // armOwnerLeftSignal); the server-side listener
  // (src/utils/owner-left-listener.js + orchestrator + handler) consumes
  // the signal and atomically transitions the room to OWNER_AWAY or
  // CLOSED via decideOwnerLeftAction. The lazy-reap path in
  // src/utils/stale-room-reap.js remains as defense-in-depth on the
  // `inRoomTransaction` chokepoint (PR #996). No cron, no GH Actions
  // workflow, no system endpoint needed for stale-room cleanup.
 
  // Backup user profiles + cleanup old closed rooms — daily 02:00 UTC
  cron.schedule('0 2 * * *', () => {
    log.info('cron', 'Running backups + closedRooms');
    backups().catch((err) => log.error('cron', 'backups failed', { error: err.message }));
    closedRooms().catch((err) => log.error('cron', 'closedRooms failed', { error: err.message }));
  });
 
  // Cleanup orphaned storage — daily 04:00 UTC
  cron.schedule('0 4 * * *', () => {
    log.info('cron', 'Running orphanedStorage');
    orphanedStorage().catch((err) =>
      log.error('cron', 'orphanedStorage failed', { error: err.message }),
    );
  });
 
  // Rotate logs — every hour
  cron.schedule('0 * * * *', () => {
    log.info('cron', 'Running rotateLogs');
    rotateLogs().catch((err) => log.error('cron', 'rotateLogs failed', { error: err.message }));
  });
 
  // accountDeletion migrated to a GitHub Actions scheduled workflow:
  //   - .github/workflows/cron-account-deletion.yml      0 3 * * *
  // POSTs to /api/system/sweep-account-deletions; the underlying
  // function lives in src/cron/accountDeletion.js.
  //
  // expireBans ELIMINATED — ban expiry is decided at read time by
  // `isBanActive` in utils/bans.js, and lapsed network bans are lazily
  // reaped on the way through `getActiveNetworkBans`. No sweep, no GH
  // Actions workflow, no system endpoint needed.
  //
  // NOT a Firestore range filter: `where('expiresAt','>',nowIso)` compares
  // ISO strings by codepoint, so a corrupt expiry silently retires the ban
  // (SHY-0149). The predicate fails closed instead.
 
  // Expire data exports — daily 04:00 UTC
  cron.schedule('0 4 * * *', () => {
    log.info('cron', 'Running expireDataExports');
    expireDataExports().catch((err) =>
      log.error('cron', 'expireDataExports failed', { error: err.message }),
    );
  });
 
  // dispatchNotifications ELIMINATED — roadmap notifications now
  // fan out INLINE via `dispatchNotificationInline` in
  // utils/notification-channels.js, called from utils/roadmap-notify.js
  // with Promise.allSettled per subscriber. Fire-and-forget at the
  // route layer (suggestions.js routes use `.catch(...)`), so the
  // admin HTTP response returns immediately. No queue, no cron, no
  // workflow, no system endpoint.
 
  // Age-verification audit-log reconciliation — daily 05:00 UTC.
  // Back-fills missing audit entries for decisions whose post-commit
  // audit write failed (compliance gap fix). 7-day scan window so a
  // multi-day Firestore outage is fully covered. Idempotent on
  // re-runs via `details.fromSubmissionId` markers.
  //
  // Per-doc failures are isolated inside the job (see `failed`
  // counter); only a catastrophic crash (Firestore unreachable,
  // permission revoked, code bug) reaches this `.catch`. That kind
  // of failure leaves the OSA/GDPR remediation gap unresolved for
  // 24+ h, so route it through alertManager instead of relying on
  // log-grep.
  cron.schedule('0 5 * * *', () => {
    log.info('cron', 'Running ageVerificationAuditReconcile');
    ageVerificationAuditReconcile().catch((err) => {
      log.error('cron', 'ageVerificationAuditReconcile failed', { error: err.message });
      alertManager
        .createAlert(
          'compliance_cron_failed',
          'critical',
          'Age-verification audit reconcile cron crashed',
          'Daily back-fill of missing age-verification audit-log entries did not complete. OSA/GDPR remediation paused until the next successful run. Investigate immediately.',
          { error: err.message, cron: 'ageVerificationAuditReconcile' },
        )
        .catch((alertErr) =>
          log.error('cron', 'alertManager.createAlert failed', { error: alertErr.message }),
        );
    });
  });
 
  log.info('cron', 'Cron jobs scheduled');
}
 
module.exports = { startCronJobs };