refactor(functions): B6c split game part-finished trigger + reorder reads (#11)

Split the single broad onGamePartFinished (couples/{coupleId}/{gameType}/{sessionId}
wildcard, which fired a no-op invocation on every write to ANY couple subcollection) into
four narrow, explicitly-pathed triggers sharing one handler:
onThisOrThatPartFinished, onWheelPartFinished, onHowWellPartFinished, onDesireSyncPartFinished.
Behavior is identical for the four game collections; the spurious invocations for
messages/reactions/etc. are eliminated. (Background triggers have no client name dependency;
the old export is dropped and the four deploy fresh — the deploy runbook already accounts for
this.)

onGameSessionUpdate: move the `!change.after.exists` deletion guard ABOVE its four reads
(session/couple/userA/userB) so a delete/no-op event returns before doing any reads. The
delicate exactly-once claim-flag logic is otherwise untouched.

Build clean; 70 tests green. Discovery loads all four split triggers as v2 in us-central1
(36 functions total); old onGamePartFinished gone. dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-08 00:08:18 -05:00
parent eb4bab0b90
commit 4c077c4d7c
6 changed files with 92 additions and 59 deletions

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.onGamePartFinished = exports.onGameSessionUpdate = void 0;
exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = void 0;
const admin = __importStar(require("firebase-admin"));
const firestore_1 = require("firebase-functions/v2/firestore");
const quietHours_1 = require("../notifications/quietHours");
@ -55,6 +55,8 @@ exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupl
const change = event.data;
if (!change)
return;
if (!change.after.exists)
return; // deletion — nothing to notify (skip the four reads below)
const db = admin.firestore();
const messaging = admin.messaging();
// Get the session document
@ -89,8 +91,6 @@ exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupl
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
const dataFor = (uid) => (uid === partnerA ? userA.data() : userB.data());
const currentData = (_e = change.after.data()) !== null && _e !== void 0 ? _e : {};
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
@ -203,14 +203,14 @@ exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupl
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
*
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
*
* Deployed as four narrow, explicitly-pathed triggers (one per async-game collection) sharing the
* handler below, rather than one broad `{gameType}` wildcard the wildcard fired a (no-op)
* invocation on every write to ANY couple subcollection (messages, reactions, ); the narrow paths
* only fire for the four game collections.
*/
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync'];
exports.onGamePartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/{gameType}/{sessionId}', async (event) => {
async function handleGamePartFinished(coupleId, gameType, sessionId, change) {
var _a, _b, _c, _d, _e;
const { coupleId, gameType, sessionId } = event.params;
if (!ASYNC_GAME_COLLECTIONS.includes(gameType))
return; // ignore messages/reactions/etc.
const change = event.data;
if (!(change === null || change === void 0 ? void 0 : change.after.exists))
return;
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {});
@ -247,7 +247,11 @@ exports.onGamePartFinished = (0, firestore_1.onDocumentWritten)('couples/{couple
const finisherName = 'Your partner'; // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
});
}
exports.onThisOrThatPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/this_or_that/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'this_or_that', event.params.sessionId, event.data));
exports.onWheelPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/wheel/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'wheel', event.params.sessionId, event.data));
exports.onHowWellPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/how_well/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'how_well', event.params.sessionId, event.data));
exports.onDesireSyncPartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/desire_sync/{sessionId}', (event) => handleGamePartFinished(event.params.coupleId, 'desire_sync', event.params.sessionId, event.data));
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.

File diff suppressed because one or more lines are too long

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
exports.wrapReleaseKeyCallable = exports.onDesireSyncPartFinished = exports.onHowWellPartFinished = exports.onWheelPartFinished = exports.onThisOrThatPartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined.
require("./options");
@ -103,7 +103,10 @@ var onUserDelete_1 = require("./users/onUserDelete");
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGameSessionUpdate; } });
Object.defineProperty(exports, "onGamePartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onGamePartFinished; } });
Object.defineProperty(exports, "onThisOrThatPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onThisOrThatPartFinished; } });
Object.defineProperty(exports, "onWheelPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onWheelPartFinished; } });
Object.defineProperty(exports, "onHowWellPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onHowWellPartFinished; } });
Object.defineProperty(exports, "onDesireSyncPartFinished", { enumerable: true, get: function () { return onGameSessionUpdate_1.onDesireSyncPartFinished; } });
var wrapReleaseKeyCallable_1 = require("./releaseKey/wrapReleaseKeyCallable");
Object.defineProperty(exports, "wrapReleaseKeyCallable", { enumerable: true, get: function () { return wrapReleaseKeyCallable_1.wrapReleaseKeyCallable; } });
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAMoC;AALlC,0HAAA,mBAAmB,OAAA;AACnB,+HAAA,wBAAwB,OAAA;AACxB,0HAAA,mBAAmB,OAAA;AACnB,4HAAA,qBAAqB,OAAA;AACrB,+HAAA,wBAAwB,OAAA;AAG1B,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -1,5 +1,5 @@
import * as admin from 'firebase-admin'
import { onDocumentWritten } from 'firebase-functions/v2/firestore'
import { onDocumentWritten, Change } from 'firebase-functions/v2/firestore'
import { recipientInQuietHours } from '../notifications/quietHours'
import { sendPushToUser } from '../notifications/push'
import { logger } from '../log'
@ -21,6 +21,7 @@ export const onGameSessionUpdate = onDocumentWritten(
const change = event.data
if (!change) return
if (!change.after.exists) return // deletion — nothing to notify (skip the four reads below)
const db = admin.firestore()
const messaging = admin.messaging()
@ -62,7 +63,6 @@ export const onGameSessionUpdate = onDocumentWritten(
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.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)
@ -189,54 +189,74 @@ export const onGameSessionUpdate = onDocumentWritten(
* PARTNER_COMPLETED_PART client route already exists; this is the trigger that finally emits it.
*
* Path: couples/{coupleId}/{gameType}/{sessionId} (answer doc; same id as the session doc).
*
* Deployed as four narrow, explicitly-pathed triggers (one per async-game collection) sharing the
* handler below, rather than one broad `{gameType}` wildcard the wildcard fired a (no-op)
* invocation on every write to ANY couple subcollection (messages, reactions, ); the narrow paths
* only fire for the four game collections.
*/
const ASYNC_GAME_COLLECTIONS = ['this_or_that', 'wheel', 'how_well', 'desire_sync']
export const onGamePartFinished = onDocumentWritten(
'couples/{coupleId}/{gameType}/{sessionId}',
async (event) => {
const { coupleId, gameType, sessionId } = event.params
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc.
const change = event.data
if (!change?.after.exists) return
async function handleGamePartFinished(
coupleId: string,
gameType: string,
sessionId: string,
change: Change<admin.firestore.DocumentSnapshot> | undefined
): Promise<void> {
if (!change?.after.exists) return
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
const answerUids = Object.keys(answers)
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
// partner_finished_game instead.
if (answerUids.length !== 1) return
const finisherUid = answerUids[0]
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
const answerUids = Object.keys(answers)
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
// partner_finished_game instead.
if (answerUids.length !== 1) return
const finisherUid = answerUids[0]
const db = admin.firestore()
const messaging = admin.messaging()
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
const db = admin.firestore()
const messaging = admin.messaging()
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
if (!fresh.exists || !d) return false
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
return true
})
if (!claimed) return
// Claim a one-time flag on the SESSION doc (consistent with start/finishNotifiedAt; rule-safe;
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
if (!fresh.exists || !d) return false
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
return true
})
if (!claimed) return
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const recipient = userIds.find((u) => u !== finisherUid)
if (!recipient) return
const finisher = await db.collection('users').doc(finisherUid).get()
const finisherName = 'Your partner' // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const recipient = userIds.find((u) => u !== finisherUid)
if (!recipient) return
const finisher = await db.collection('users').doc(finisherUid).get()
const finisherName = 'Your partner' // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
await notifyPartner(
db, messaging, recipient, finisherName, gameType,
'partner_completed_part', yourTurnBody(gameType), coupleId,
finisherAvatar, sessionId
)
}
await notifyPartner(
db, messaging, recipient, finisherName, gameType,
'partner_completed_part', yourTurnBody(gameType), coupleId,
finisherAvatar, sessionId
)
}
export const onThisOrThatPartFinished = onDocumentWritten(
'couples/{coupleId}/this_or_that/{sessionId}',
(event) => handleGamePartFinished(event.params.coupleId, 'this_or_that', event.params.sessionId, event.data)
)
export const onWheelPartFinished = onDocumentWritten(
'couples/{coupleId}/wheel/{sessionId}',
(event) => handleGamePartFinished(event.params.coupleId, 'wheel', event.params.sessionId, event.data)
)
export const onHowWellPartFinished = onDocumentWritten(
'couples/{coupleId}/how_well/{sessionId}',
(event) => handleGamePartFinished(event.params.coupleId, 'how_well', event.params.sessionId, event.data)
)
export const onDesireSyncPartFinished = onDocumentWritten(
'couples/{coupleId}/desire_sync/{sessionId}',
(event) => handleGamePartFinished(event.params.coupleId, 'desire_sync', event.params.sessionId, event.data)
)
/**

View File

@ -43,7 +43,13 @@ export { submitOutcomeCallable } from './couples/submitOutcomeCallable'
export { aggregateOutcomeStats } from './couples/aggregateOutcomes'
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
export { onUserDelete } from './users/onUserDelete'
export { onGameSessionUpdate, onGamePartFinished } from './games/onGameSessionUpdate'
export {
onGameSessionUpdate,
onThisOrThatPartFinished,
onWheelPartFinished,
onHowWellPartFinished,
onDesireSyncPartFinished,
} from './games/onGameSessionUpdate'
export { wrapReleaseKeyCallable } from './releaseKey/wrapReleaseKeyCallable'