All files / src/utils owner-left-handler.js

100% Statements 20/20
100% Branches 14/14
100% Functions 2/2
100% Lines 16/16

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                                                    3x 3x   3x                                   49x 34x 30x 24x 15x                                   37x 9x 9x 9x   28x       11x     17x     3x          
/**
 * Owner-left handler — server-side event-driven response to the
 * `ownerLeft/{roomId}` RTDB signal (armed by the owner client via
 * `onDisconnect().setValue(...)` when entering an ACTIVE room).
 *
 * Replaces the staleRooms cron's role in detecting and acting on owner
 * disconnects. The cron polled every 5 minutes; this handler reacts within
 * RTDB's onDisconnect latency (seconds). The operator's refined state machine
 * (2026-06-04) determines the action:
 *
 *   - if the owner is still present somewhere (multi-device, reconnect within
 *     the handler's processing window) — NOOP
 *   - if the room is no longer ACTIVE (already AWAY/CLOSED via a concurrent
 *     path) — NOOP
 *   - if ACTIVE and at least one non-owner is OCCUPIED on a seat — transition
 *     to OWNER_AWAY (the existing client-driven countdown will then close it)
 *   - if ACTIVE and no non-owner is seated — close the room IMMEDIATELY
 *     (the cron's "no holdouts" branch would have closed it; we just don't
 *     wait the 5-minute tick)
 *
 * The decision is pure (`decideOwnerLeftAction`), the application is the
 * transactional wrapper (`applyOwnerLeftTx`). The orchestrator (which performs
 * the RTDB presence re-check, opens the Firestore transaction, applies the
 * action, and clears the signal) lives in the listener module.
 */
 
const { hasNonOwnerSeated } = require('./room-auth');
const { reapStaleRoomTx } = require('./stale-room-reap');
 
const OWNER_LEFT_ACTION = Object.freeze({
  NOOP: 'NOOP',
  OWNER_AWAY: 'OWNER_AWAY',
  CLOSE_IMMEDIATE: 'CLOSE_IMMEDIATE',
});
 
/**
 * Decide what to do given the room's current Firestore shape + whether the
 * owner is still present in RTDB (re-checked by the caller AFTER the signal
 * fired, to close the TOCTOU window where the owner reconnects between
 * signal arrival and handler execution, or is present on another device).
 *
 * @param {object|null|undefined} room - the room doc (from Firestore.get())
 * @param {boolean} ownerStillPresent - result of the post-signal isUserPresent
 *   re-check; if true, do nothing
 * @returns {string} one of OWNER_LEFT_ACTION values
 */
function decideOwnerLeftAction(room, ownerStillPresent) {
  if (ownerStillPresent) return OWNER_LEFT_ACTION.NOOP;
  if (!room || !room.state) return OWNER_LEFT_ACTION.NOOP;
  if (room.state !== 'ACTIVE') return OWNER_LEFT_ACTION.NOOP;
  if (hasNonOwnerSeated(room)) return OWNER_LEFT_ACTION.OWNER_AWAY;
  return OWNER_LEFT_ACTION.CLOSE_IMMEDIATE;
}
 
/**
 * Apply the decided action inside a Firestore transaction.
 *
 * NOOP / unknown actions are intentionally a no-op write (zero t.update calls)
 * so the caller can safely invoke this for every signal event without branching
 * on the action upstream.
 *
 * @param {FirebaseFirestore.Transaction} t
 * @param {FirebaseFirestore.DocumentReference} roomRef
 * @param {object} room - the pre-transition room shape (from t.get())
 * @param {string} action - one of OWNER_LEFT_ACTION values
 * @param {number} nowMs - server time at the start of this transaction
 * @returns {object} the post-transition room shape (room merged with the patch)
 */
function applyOwnerLeftTx(t, roomRef, room, action, nowMs) {
  if (action === OWNER_LEFT_ACTION.OWNER_AWAY) {
    const patch = { state: 'OWNER_AWAY', ownerLeftAt: nowMs };
    t.update(roomRef, patch);
    return { ...room, ...patch };
  }
  if (action === OWNER_LEFT_ACTION.CLOSE_IMMEDIATE) {
    // Reuse the close payload + write through reapStaleRoomTx so the close
    // shape stays a single source of truth (ownerLeftAt: null invariant from
    // PR #996's reviewer-Critical finding lives in buildClosePayload).
    return reapStaleRoomTx(t, roomRef, room, nowMs);
  }
  // NOOP or unknown — return the room as-is, no write.
  return room;
}
 
module.exports = {
  OWNER_LEFT_ACTION,
  decideOwnerLeftAction,
  applyOwnerLeftTx,
};