fix(notif): replace status-diff with idempotent flag-claim for game start/finish pushes (F-RACE-001)
This commit is contained in:
parent
e6a8deef67
commit
6e79cd9704
|
|
@ -45,8 +45,12 @@ const admin = __importStar(require("firebase-admin"));
|
||||||
exports.onGameSessionUpdate = functions.firestore
|
exports.onGameSessionUpdate = functions.firestore
|
||||||
.document('couples/{coupleId}/sessions/{sessionId}')
|
.document('couples/{coupleId}/sessions/{sessionId}')
|
||||||
.onWrite(async (change, context) => {
|
.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;
|
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 db = admin.firestore();
|
||||||
const messaging = admin.messaging();
|
const messaging = admin.messaging();
|
||||||
// Get the session document
|
// 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 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 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;
|
const avatarB = (_h = userB.data()) === null || _h === void 0 ? void 0 : _h.photoUrl;
|
||||||
// Check if session was just created (status = "active")
|
const currentData = (_j = change.after.data()) !== null && _j !== void 0 ? _j : {};
|
||||||
const previousData = (_j = change.before.data()) !== null && _j !== void 0 ? _j : {};
|
if (!change.after.exists)
|
||||||
const currentData = (_k = change.after.data()) !== null && _k !== void 0 ? _k : {};
|
return; // deletion — nothing to notify
|
||||||
const wasInactive = ((_l = previousData.status) !== null && _l !== void 0 ? _l : '') !== 'active';
|
const status = currentData.status;
|
||||||
const isActiveNow = currentData.status === 'active';
|
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
|
||||||
if (wasInactive && isActiveNow) {
|
// Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the
|
||||||
// New session started — notify the OTHER partner, naming the person who started it.
|
// 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 startedBy = currentData.startedByUserId;
|
||||||
const gameType = (_m = currentData.gameType) !== null && _m !== void 0 ? _m : 'wheel';
|
const gameType = (_k = currentData.gameType) !== null && _k !== void 0 ? _k : 'wheel';
|
||||||
const recipientId = startedBy === partnerA ? partnerB : partnerA;
|
const recipientId = startedBy === partnerA ? partnerB : partnerA;
|
||||||
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
|
const starterName = startedBy === partnerA ? partnerAName : partnerBName;
|
||||||
const starterAvatar = startedBy === partnerA ? avatarA : avatarB;
|
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);
|
await notifyPartner(db, messaging, recipientId, starterName, gameType, 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar, sessionId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check if session was completed
|
// ── Session completed (reveal ready for both) ────────────────────────
|
||||||
const wasActive = ((_o = previousData.status) !== null && _o !== void 0 ? _o : '') === 'active';
|
if (status === 'completed' && !currentData.finishNotifiedAt) {
|
||||||
const isCompletedNow = currentData.status === 'completed';
|
const claimed = await db.runTransaction(async (tx) => {
|
||||||
if (wasActive && isCompletedNow) {
|
const fresh = await tx.get(sessionRef);
|
||||||
// The session is complete (both partners have answered) — the reveal is ready for each of
|
const d = fresh.data();
|
||||||
// them, so notify BOTH, each naming the OTHER partner.
|
if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt)
|
||||||
const gt = (_p = currentData.gameType) !== null && _p !== void 0 ? _p : 'wheel';
|
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, 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);
|
await notifyPartner(db, messaging, partnerB, partnerAName, gt, 'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId, avatarA, sessionId);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -12,6 +12,10 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
.onWrite(async (change, context) => {
|
.onWrite(async (change, context) => {
|
||||||
const { coupleId, sessionId } = context.params as { coupleId: string; sessionId: string }
|
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 db = admin.firestore()
|
||||||
const messaging = admin.messaging()
|
const messaging = admin.messaging()
|
||||||
|
|
||||||
|
|
@ -48,15 +52,29 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
const avatarA = userA.data()?.photoUrl as string | undefined
|
const avatarA = userA.data()?.photoUrl as string | undefined
|
||||||
const avatarB = userB.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() ?? {}
|
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'
|
// Detect start/finish from the session's CURRENT state + a one-time flag, NOT from the
|
||||||
const isActiveNow = currentData.status === 'active'
|
// 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 ──────────────────────────────────────────────
|
||||||
// New session started — notify the OTHER partner, naming the person who started it.
|
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 startedBy = currentData.startedByUserId
|
||||||
const gameType = currentData.gameType ?? 'wheel'
|
const gameType = currentData.gameType ?? 'wheel'
|
||||||
const recipientId = startedBy === partnerA ? partnerB : partnerA
|
const recipientId = startedBy === partnerA ? partnerB : partnerA
|
||||||
|
|
@ -67,17 +85,22 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
|
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
|
||||||
starterAvatar, sessionId
|
starterAvatar, sessionId
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session was completed
|
// ── Session completed (reveal ready for both) ────────────────────────
|
||||||
const wasActive = (previousData.status ?? '') === 'active'
|
if (status === 'completed' && !currentData.finishNotifiedAt) {
|
||||||
const isCompletedNow = currentData.status === 'completed'
|
const claimed = await db.runTransaction(async (tx) => {
|
||||||
|
const fresh = await tx.get(sessionRef)
|
||||||
if (wasActive && isCompletedNow) {
|
const d = fresh.data()
|
||||||
// The session is complete (both partners have answered) — the reveal is ready for each of
|
if (!fresh.exists || !d || d.status !== 'completed' || d.finishNotifiedAt) return false
|
||||||
// them, so notify BOTH, each naming the OTHER partner.
|
tx.update(sessionRef, { finishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if (claimed) {
|
||||||
const gt = currentData.gameType ?? 'wheel'
|
const gt = currentData.gameType ?? 'wheel'
|
||||||
|
// Notify BOTH partners, each naming the OTHER.
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, partnerA, partnerBName, gt,
|
db, messaging, partnerA, partnerBName, gt,
|
||||||
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
|
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
|
||||||
|
|
@ -88,6 +111,7 @@ export const onGameSessionUpdate = functions.firestore
|
||||||
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
|
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
|
||||||
avatarA, sessionId
|
avatarA, sessionId
|
||||||
)
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue