fix(notif): replace status-diff with idempotent flag-claim for game start/finish pushes (F-RACE-001)

This commit is contained in:
null 2026-06-26 20:04:05 -05:00
parent e6a8deef67
commit 6e79cd9704
3 changed files with 110 additions and 58 deletions

View File

@ -45,8 +45,12 @@ const admin = __importStar(require("firebase-admin"));
exports.onGameSessionUpdate = functions.firestore
.document('couples/{coupleId}/sessions/{sessionId}')
.onWrite(async (change, context) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
const { coupleId, sessionId } = context.params;
// The per-couple active-session lock lives at sessions/_active — it is a pointer, not a
// game session, so it must never produce a partner notification.
if (sessionId === '_active')
return;
const db = admin.firestore();
const messaging = admin.messaging();
// Get the session document
@ -77,30 +81,54 @@ exports.onGameSessionUpdate = functions.firestore
const partnerBName = (_f = (_e = userB.data()) === null || _e === void 0 ? void 0 : _e.displayName) !== null && _f !== void 0 ? _f : 'Partner B';
const avatarA = (_g = userA.data()) === null || _g === void 0 ? void 0 : _g.photoUrl;
const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl;
// Check if session was just created (status = "active")
const previousData = (_j = change.before.data()) !== null && _j !== void 0 ? _j : {};
const currentData = (_k = change.after.data()) !== null && _k !== void 0 ? _k : {};
const wasInactive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') !== 'active';
const isActiveNow = currentData.status === 'active';
if (wasInactive && isActiveNow) {
// New session started — notify the OTHER partner, naming the person who started it.
const startedBy = currentData.startedByUserId;
const gameType = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel';
const recipientId = startedBy === partnerA ? partnerB : partnerA;
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId);
const currentData = (_j = change.after.data()) !== null && _j !== void 0 ? _j : {};
if (!change.after.exists)
return; // deletion — nothing to notify
const status = currentData.status;
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
// Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the
// change.before→change.after status diff. The atomic session start (F-RACE-001) writes the
// session doc AND the sessions/_active pointer in a single transaction; transactional writes
// can be delivered to onWrite with change.before === change.after (the "Snapshot has no
// readTime" path), which made the inactive→active edge unreliable and intermittently dropped
// the partner_started_game push. Claiming a flag inside a transaction makes each notification
// fire exactly once no matter how the event is delivered (and prevents double-sends).
// ── New session started ──────────────────────────────────────────────
if (status === 'active' && !currentData.startNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d || d.status !== 'active' || d.startNotifiedAt)
return false;
tx.update(sessionRef, { startNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (claimed) {
const startedBy = currentData.startedByUserId;
const gameType = (_k = currentData.gameType) !== null && _k !== void 0 ? _k : 'wheel';
const recipientId = startedBy === partnerA ? partnerB : partnerA;
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId);
}
return;
}
// Check if session was completed
const wasActive = ((_o = previousData.status) !== null && _o !== void 0 ? _o : '') === 'active';
const isCompletedNow = currentData.status === 'completed';
if (wasActive && isCompletedNow) {
// The session is complete (both partners have answered) — the reveal is ready for each of
// them, so notify BOTH, each naming the OTHER partner.
const gt = (_p = currentData.gameType) !== null && _p !== void 0 ? _p : 'wheel';
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId);
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId);
// ── Session completed (reveal ready for both) ────────────────────────
if (status === 'completed' && !currentData.finishNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt)
return false;
tx.update(sessionRef, { finishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() });
return true;
});
if (claimed) {
const gt = (_l = currentData.gameType) !== null && _l !== void 0 ? _l : 'wheel';
// Notify BOTH partners, each naming the OTHER.
await notifyPartner(db, messaging, partnerA, partnerBName, gt, 'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId, avatarB, sessionId);
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId);
}
return;
}
});

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,10 @@ export const onGameSessionUpdate = functions.firestore
.onWrite(async (change, context) => {
const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string }
// The per-couple active-session lock lives at sessions/_active — it is a pointer, not a
// game session, so it must never produce a partner notification.
if (sessionId === '_active') return
const db = admin.firestore()
const messaging = admin.messaging()
@ -48,46 +52,66 @@ export const onGameSessionUpdate = functions.firestore
const avatarA = userA.data()?.photoUrl as string | undefined
const avatarB = userB.data()?.photoUrl as string | undefined
// Check if session was just created (status = "active")
const previousData = change.before.data() ?? {}
const currentData = change.after.data() ?? {}
if (!change.after.exists) return // deletion — nothing to notify
const status = currentData.status as string | undefined
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
const wasInactive = (previousData.status ?? '') !== 'active'
const isActiveNow = currentData.status === 'active'
// Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the
// change.before→change.after status diff. The atomic session start (F-RACE-001) writes the
// session doc AND the sessions/_active pointer in a single transaction; transactional writes
// can be delivered to onWrite with change.before === change.after (the "Snapshot has no
// readTime" path), which made the inactive→active edge unreliable and intermittently dropped
// the partner_started_game push. Claiming a flag inside a transaction makes each notification
// fire exactly once no matter how the event is delivered (and prevents double-sends).
if (wasInactive && isActiveNow) {
// New session started — notify the OTHER partner, naming the person who started it.
const startedBy = currentData.startedByUserId
const gameType = currentData.gameType ?? 'wheel'
const recipientId = startedBy === partnerA ? partnerB : partnerA
const starterName = startedBy === partnerA ? partnerAName : partnerBName
const starterAvatar = startedBy === partnerA ? avatarA : avatarB
await notifyPartner(
db, messaging, recipientId, starterName, gameType,
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
starterAvatar, sessionId
)
// ── New session started ──────────────────────────────────────────────
if (status === 'active' && !currentData.startNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
if (!fresh.exists || !d || d.status !== 'active' || d.startNotifiedAt) return false
tx.update(sessionRef, { startNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
return true
})
if (claimed) {
const startedBy = currentData.startedByUserId
const gameType = currentData.gameType ?? 'wheel'
const recipientId = startedBy === partnerA ? partnerB : partnerA
const starterName = startedBy === partnerA ? partnerAName : partnerBName
const starterAvatar = startedBy === partnerA ? avatarA : avatarB
await notifyPartner(
db, messaging, recipientId, starterName, gameType,
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
starterAvatar, sessionId
)
}
return
}
// Check if session was completed
const wasActive = (previousData.status ?? '') === 'active'
const isCompletedNow = currentData.status === 'completed'
if (wasActive && isCompletedNow) {
// The session is complete (both partners have answered) — the reveal is ready for each of
// them, so notify BOTH, each naming the OTHER partner.
const gt = currentData.gameType ?? 'wheel'
await notifyPartner(
db, messaging, partnerA, partnerBName, gt,
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
avatarB, sessionId
)
await notifyPartner(
db, messaging, partnerB, partnerAName, gt,
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
avatarA, sessionId
)
// ── Session completed (reveal ready for both) ────────────────────────
if (status === 'completed' && !currentData.finishNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt) return false
tx.update(sessionRef, { finishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
return true
})
if (claimed) {
const gt = currentData.gameType ?? 'wheel'
// Notify BOTH partners, each naming the OTHER.
await notifyPartner(
db, messaging, partnerA, partnerBName, gt,
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
avatarB, sessionId
)
await notifyPartner(
db, messaging, partnerB, partnerAName, gt,
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
avatarA, sessionId
)
}
return
}
})