All files / src/utils appeals.js

93.33% Statements 14/15
75% Branches 3/4
100% Functions 3/3
93.33% Lines 14/15

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                                        25x 25x 25x     25x                     29x 29x             29x                         17x             17x                     12x 12x 12x                           12x     25x  
/**
 * Suspension appeals — one schema, written in one place (SHY-0463).
 *
 * Two routes accept an appeal: `POST /api/users/:uniqueId/appeal`, which the
 * app calls, and `POST /api/appeals`, which the web and admin tooling call.
 * They used to write the SAME collection with different shapes and disagree
 * about what "already pending" meant, and both halves of that disagreement
 * cost a suspended person the right to answer an accusation:
 *
 *   - the app's route wrote `uniqueId` (a String from the route param) where
 *     every reader looks for `userId` (a Number), so an appeal from the phone
 *     reached the moderation queue with no user attached to it; and
 *   - it decided "already pending" from a flag on the user document that only
 *     one of the three suspension-ending writers clears, so after a single
 *     appeal every LATER suspension was refused 409 for ever.
 *
 * The schema and the duplicate check therefore live here rather than in either
 * route. Two routes stating the same fact is how they came to disagree.
 */
 
const { db } = require('./firebase');
const { generateId, now } = require('./helpers');
const { queryDocs } = require('./firestore-helpers');
 
/** The collection both routes write. */
const APPEALS_COLLECTION = 'suspensionAppeals';
 
/**
 * Normalise an owner id to the type every reader compares against.
 *
 * `req.auth.uniqueId` is a Number and `req.params.uniqueId` is a String, so
 * the two callers arrive with different types for the same person. Firestore
 * equality is typed: a String owner id matches nothing, while still looking
 * present in the admin console.
 */
function toOwnerId(uniqueId) {
  const id = Number(uniqueId);
  Iif (!Number.isInteger(id)) {
    // Explicit, for the reason `requireOwner` gives (Audit L1): `Number()` on a
    // non-numeric value yields NaN silently, and `userId: NaN` would land in
    // Firestore as a row that looks present in the admin console and matches
    // no query ever again — the precise failure this ticket exists to end.
    throw new Error(`appeal owner id must be an integer, received: ${uniqueId}`);
  }
  return id;
}
 
/**
 * The pending appeal for this person, or null.
 *
 * Read from the appeals collection, never from a flag on the user document:
 * an appeal that has been resolved or deleted stops blocking the next one,
 * which is the behaviour "already pending" is supposed to describe.
 */
async function findPendingAppeal(uniqueId) {
  // `queryDocs` rather than a raw `.get()`: it is how the rest of these routes
  // read Firestore, and it is the seam the existing suites already stand on.
  const rows = await queryDocs(
    db
      .collection(APPEALS_COLLECTION)
      .where('userId', '==', toOwnerId(uniqueId))
      .where('status', '==', 'pending')
      .limit(1),
  );
  return rows.length > 0 ? rows[0] : null;
}
 
/**
 * Record an appeal and mark the user doc as having one pending.
 *
 * `suspensionAppealStatus` is still written — the app renders it and the admin
 * review route updates it — but nothing GATES on it any more. It is a display
 * mirror of the collection, not a second source of truth.
 */
async function createAppeal({ uniqueId, appealText }) {
  const userId = toOwnerId(uniqueId);
  const appealId = generateId();
  await Promise.all([
    db.doc(`${APPEALS_COLLECTION}/${appealId}`).set(
      {
        userId,
        appealText,
        status: 'pending',
        reviewedBy: null,
        reviewedAt: null,
        createdAt: now(),
      },
      { merge: true },
    ),
    db.doc(`users/${userId}`).update({ suspensionAppealStatus: 'pending' }),
  ]);
  return appealId;
}
 
module.exports = { APPEALS_COLLECTION, toOwnerId, findPendingAppeal, createAppeal };