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:
parent
eb4bab0b90
commit
4c077c4d7c
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
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 admin = __importStar(require("firebase-admin"));
|
||||||
const firestore_1 = require("firebase-functions/v2/firestore");
|
const firestore_1 = require("firebase-functions/v2/firestore");
|
||||||
const quietHours_1 = require("../notifications/quietHours");
|
const quietHours_1 = require("../notifications/quietHours");
|
||||||
|
|
@ -55,6 +55,8 @@ exports.onGameSessionUpdate = (0, firestore_1.onDocumentWritten)('couples/{coupl
|
||||||
const change = event.data;
|
const change = event.data;
|
||||||
if (!change)
|
if (!change)
|
||||||
return;
|
return;
|
||||||
|
if (!change.after.exists)
|
||||||
|
return; // deletion — nothing to notify (skip the four reads below)
|
||||||
const db = admin.firestore();
|
const db = admin.firestore();
|
||||||
const messaging = admin.messaging();
|
const messaging = admin.messaging();
|
||||||
// Get the session document
|
// 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.
|
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
|
||||||
const dataFor = (uid) => (uid === partnerA ? userA.data() : userB.data());
|
const dataFor = (uid) => (uid === partnerA ? userA.data() : userB.data());
|
||||||
const currentData = (_e = change.after.data()) !== null && _e !== void 0 ? _e : {};
|
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 status = currentData.status;
|
||||||
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId);
|
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
|
// 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.
|
* 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).
|
* 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'];
|
async function handleGamePartFinished(coupleId, gameType, sessionId, change) {
|
||||||
exports.onGamePartFinished = (0, firestore_1.onDocumentWritten)('couples/{coupleId}/{gameType}/{sessionId}', async (event) => {
|
|
||||||
var _a, _b, _c, _d, _e;
|
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))
|
if (!(change === null || change === void 0 ? void 0 : change.after.exists))
|
||||||
return;
|
return;
|
||||||
const answers = ((_b = (_a = change.after.data()) === null || _a === void 0 ? void 0 : _a.answers) !== null && _b !== void 0 ? _b : {});
|
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 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;
|
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);
|
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
|
* 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.
|
* 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
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
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
|
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so
|
||||||
// setGlobalOptions runs before the functions below are defined.
|
// setGlobalOptions runs before the functions below are defined.
|
||||||
require("./options");
|
require("./options");
|
||||||
|
|
@ -103,7 +103,10 @@ var onUserDelete_1 = require("./users/onUserDelete");
|
||||||
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
|
Object.defineProperty(exports, "onUserDelete", { enumerable: true, get: function () { return onUserDelete_1.onUserDelete; } });
|
||||||
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
|
var onGameSessionUpdate_1 = require("./games/onGameSessionUpdate");
|
||||||
Object.defineProperty(exports, "onGameSessionUpdate", { enumerable: true, get: function () { return onGameSessionUpdate_1.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");
|
var wrapReleaseKeyCallable_1 = require("./releaseKey/wrapReleaseKeyCallable");
|
||||||
Object.defineProperty(exports, "wrapReleaseKeyCallable", { enumerable: true, get: function () { return wrapReleaseKeyCallable_1.wrapReleaseKeyCallable; } });
|
Object.defineProperty(exports, "wrapReleaseKeyCallable", { enumerable: true, get: function () { return wrapReleaseKeyCallable_1.wrapReleaseKeyCallable; } });
|
||||||
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
|
// NOTE (security review Batch 2): the unauthenticated public `health` HTTP endpoint
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as admin from 'firebase-admin'
|
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 { recipientInQuietHours } from '../notifications/quietHours'
|
||||||
import { sendPushToUser } from '../notifications/push'
|
import { sendPushToUser } from '../notifications/push'
|
||||||
import { logger } from '../log'
|
import { logger } from '../log'
|
||||||
|
|
@ -21,6 +21,7 @@ export const onGameSessionUpdate = onDocumentWritten(
|
||||||
|
|
||||||
const change = event.data
|
const change = event.data
|
||||||
if (!change) return
|
if (!change) return
|
||||||
|
if (!change.after.exists) return // deletion — nothing to notify (skip the four reads below)
|
||||||
|
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
const messaging = admin.messaging()
|
const messaging = admin.messaging()
|
||||||
|
|
@ -62,7 +63,6 @@ export const onGameSessionUpdate = onDocumentWritten(
|
||||||
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.data())
|
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.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 status = currentData.status as string | undefined
|
||||||
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
|
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.
|
* 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).
|
* 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']
|
async function handleGamePartFinished(
|
||||||
export const onGamePartFinished = onDocumentWritten(
|
coupleId: string,
|
||||||
'couples/{coupleId}/{gameType}/{sessionId}',
|
gameType: string,
|
||||||
async (event) => {
|
sessionId: string,
|
||||||
const { coupleId, gameType, sessionId } = event.params
|
change: Change<admin.firestore.DocumentSnapshot> | undefined
|
||||||
if (!ASYNC_GAME_COLLECTIONS.includes(gameType)) return // ignore messages/reactions/etc.
|
): Promise<void> {
|
||||||
const change = event.data
|
if (!change?.after.exists) return
|
||||||
if (!change?.after.exists) return
|
|
||||||
|
|
||||||
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
|
const answers = (change.after.data()?.answers ?? {}) as Record<string, unknown>
|
||||||
const answerUids = Object.keys(answers)
|
const answerUids = Object.keys(answers)
|
||||||
// Only the FIRST finisher (exactly one answer present) nudges the partner. Zero = session just
|
// 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
|
// created; two = both done → the session flips to completed and onGameSessionUpdate sends
|
||||||
// partner_finished_game instead.
|
// partner_finished_game instead.
|
||||||
if (answerUids.length !== 1) return
|
if (answerUids.length !== 1) return
|
||||||
const finisherUid = answerUids[0]
|
const finisherUid = answerUids[0]
|
||||||
|
|
||||||
const db = admin.firestore()
|
const db = admin.firestore()
|
||||||
const messaging = admin.messaging()
|
const messaging = admin.messaging()
|
||||||
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
|
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;
|
// 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).
|
// writing it re-fires onGameSessionUpdate but that no-ops on an active+already-started session).
|
||||||
const claimed = await db.runTransaction(async (tx) => {
|
const claimed = await db.runTransaction(async (tx) => {
|
||||||
const fresh = await tx.get(sessionRef)
|
const fresh = await tx.get(sessionRef)
|
||||||
const d = fresh.data()
|
const d = fresh.data()
|
||||||
if (!fresh.exists || !d) return false
|
if (!fresh.exists || !d) return false
|
||||||
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
|
if (d.status === 'completed' || d.partFinishNotifiedAt) return false
|
||||||
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
|
tx.update(sessionRef, { partFinishNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
if (!claimed) return
|
if (!claimed) return
|
||||||
|
|
||||||
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
||||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||||
const recipient = userIds.find((u) => u !== finisherUid)
|
const recipient = userIds.find((u) => u !== finisherUid)
|
||||||
if (!recipient) return
|
if (!recipient) return
|
||||||
const finisher = await db.collection('users').doc(finisherUid).get()
|
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 finisherName = 'Your partner' // displayName is E2EE; the app shows the real name in-app
|
||||||
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
|
const finisherAvatar = finisher.data()?.photoUrl as string | undefined
|
||||||
|
|
||||||
await notifyPartner(
|
await notifyPartner(
|
||||||
db, messaging, recipient, finisherName, gameType,
|
db, messaging, recipient, finisherName, gameType,
|
||||||
'partner_completed_part', yourTurnBody(gameType), coupleId,
|
'partner_completed_part', yourTurnBody(gameType), coupleId,
|
||||||
finisherAvatar, sessionId
|
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)
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,13 @@ export { submitOutcomeCallable } from './couples/submitOutcomeCallable'
|
||||||
export { aggregateOutcomeStats } from './couples/aggregateOutcomes'
|
export { aggregateOutcomeStats } from './couples/aggregateOutcomes'
|
||||||
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
|
export { scheduledOutcomesReminder } from './couples/scheduledOutcomesReminder'
|
||||||
export { onUserDelete } from './users/onUserDelete'
|
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'
|
export { wrapReleaseKeyCallable } from './releaseKey/wrapReleaseKeyCallable'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue