All files pairing.ts

100% Statements 46/46
100% Branches 14/14
100% Functions 7/7
100% Lines 43/43

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 1082x 2x 2x   2x   2x 1x   2x   2x           13x 13x 11x 1x   10x       6x 6x         8x 7x 1x   6x 1x   5x 5x 1x 4x         8x       4x                             2x 13x   10x 10x   8x   8x 8x     4x           4x 1x   3x 1x     2x 2x                 2x 2x     2x    
import { getApps, initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { HttpsError, onCall } from "firebase-functions/https";
 
import { isAnonymous } from "./authGuard";
 
if (getApps().length === 0) {
  initializeApp();
}
const db = getFirestore();
 
const REGION = "europe-west2";
 
type InviteSnap = { exists: boolean; get(key: string): unknown };
 
/** Require an authenticated caller who has connected a recoverable provider. */
function requireConnectedUid(auth: unknown): string {
  const uid = (auth as { uid?: string } | undefined)?.uid;
  if (!uid) throw new HttpsError("unauthenticated", "Sign in first.");
  if (isAnonymous(auth as never)) {
    throw new HttpsError("failed-precondition", "Connect your account before pairing.");
  }
  return uid;
}
 
function isExpired(expiresAt: unknown): boolean {
  const ts = expiresAt as { toMillis?: () => number } | undefined;
  return !!ts?.toMillis && ts.toMillis() < Date.now();
}
 
/** Validate the invite and return the inviter's uid, or throw a precise error. */
function validateInvite(snap: InviteSnap, uid: string): string {
  if (!snap.exists) throw new HttpsError("not-found", "That code doesn't look right.");
  if (snap.get("used") === true) {
    throw new HttpsError("failed-precondition", "That code has already been used.");
  }
  if (isExpired(snap.get("expiresAt"))) {
    throw new HttpsError("failed-precondition", "That code has expired.");
  }
  const inviterId = snap.get("inviterId") as string;
  if (inviterId === uid)
    throw new HttpsError("failed-precondition", "You can't pair with yourself.");
  return inviterId;
}
 
/** A query for any couple a member already belongs to (monogamy check). */
function soloQuery(memberId: string) {
  return db.collection("couples").where("members", "array-contains", memberId).limit(1);
}
 
function nameFrom(snap: { get(key: string): unknown }): string | null {
  return (snap.get("displayName") as string | undefined) ?? null;
}
 
/**
 * Pair the caller with the partner who minted `code`. Couples are created
 * server-side only (security rules forbid client writes to `couples`), so
 * membership can never be forged. A couple is two symmetric members.
 *
 * The caller must have connected a recoverable provider (no anonymous pairing) so
 * every couple member is recoverable. The whole read-validate-claim-create runs in
 * a single transaction, so concurrent redemptions (double-taps, two codes) can't
 * double-use an invite or leave a member in two couples. Each display name is
 * denormalized into the couple doc so a partner can be named without reading the
 * other's private user doc.
 */
export const pairWithCode = onCall<{ code: string }>({ region: REGION }, async (request) => {
  const uid = requireConnectedUid(request.auth);
 
  const code = request.data.code?.trim().toUpperCase();
  if (!code) throw new HttpsError("invalid-argument", "Missing invite code.");
 
  const inviteRef = db.doc(`invites/${code}`);
 
  const coupleId = await db.runTransaction(async (tx) => {
    const inviterId = validateInvite(await tx.get(inviteRef), uid);
 
    // All reads before any writes (Firestore transaction rule).
    const [inviteeCouples, inviterCouples, inviterUser, inviteeUser] = await Promise.all([
      tx.get(soloQuery(uid)),
      tx.get(soloQuery(inviterId)),
      tx.get(db.doc(`users/${inviterId}`)),
      tx.get(db.doc(`users/${uid}`)),
    ]);
    if (!inviteeCouples.empty) {
      throw new HttpsError("failed-precondition", "You're already paired with a partner.");
    }
    if (!inviterCouples.empty) {
      throw new HttpsError("failed-precondition", "Your partner is already paired.");
    }
 
    const coupleRef = db.collection("couples").doc();
    tx.set(coupleRef, {
      members: [inviterId, uid],
      profiles: {
        [inviterId]: { displayName: nameFrom(inviterUser) },
        [uid]: { displayName: nameFrom(inviteeUser) },
      },
      createdAt: FieldValue.serverTimestamp(),
      togetherSince: FieldValue.serverTimestamp(),
    });
    tx.update(inviteRef, { used: true, usedAt: FieldValue.serverTimestamp(), usedBy: uid });
    return coupleRef.id;
  });
 
  return { coupleId };
});