2026-06-18 00:56:21 -05:00
|
|
|
import * as admin from 'firebase-admin'
|
2026-07-08 00:08:18 -05:00
|
|
|
import { onDocumentWritten, Change } from 'firebase-functions/v2/firestore'
|
2026-06-28 10:00:25 -05:00
|
|
|
import { recipientInQuietHours } from '../notifications/quietHours'
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
import { sendPushToUser } from '../notifications/push'
|
|
|
|
|
import { logger } from '../log'
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Firestore trigger that notifies partners when a game session is created or completed.
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
*
|
2026-06-18 00:56:21 -05:00
|
|
|
* Path: couples/{coupleId}/sessions/{sessionId}
|
|
|
|
|
* Condition: onWrite (create, update, delete)
|
|
|
|
|
*/
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
export const onGameSessionUpdate = onDocumentWritten(
|
|
|
|
|
'couples/{coupleId}/sessions/{sessionId}',
|
|
|
|
|
async (event) => {
|
|
|
|
|
const { coupleId, sessionId } = event.params
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// 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
|
|
|
|
|
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
const change = event.data
|
|
|
|
|
if (!change) return
|
2026-07-08 00:08:18 -05:00
|
|
|
if (!change.after.exists) return // deletion — nothing to notify (skip the four reads below)
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
|
2026-06-18 00:56:21 -05:00
|
|
|
const db = admin.firestore()
|
|
|
|
|
const messaging = admin.messaging()
|
|
|
|
|
|
|
|
|
|
// Get the session document
|
|
|
|
|
const sessionDoc = await db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId).get()
|
|
|
|
|
const session = sessionDoc.data()
|
|
|
|
|
if (!session) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.log(`[onGameSessionUpdate] session ${sessionId} not found, skipping`)
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get couple info
|
|
|
|
|
const coupleDoc = await db.collection('couples').doc(coupleId).get()
|
|
|
|
|
if (!coupleDoc.exists) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.warn(`[onGameSessionUpdate] couple ${coupleId} not found`)
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const coupleData = coupleDoc.data() ?? {}
|
|
|
|
|
const userIds = (coupleData.userIds ?? []) as string[]
|
|
|
|
|
if (userIds.length !== 2) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.warn(`[onGameSessionUpdate] invalid couple ${coupleId}: expected 2 users, got ${userIds.length}`)
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const partnerA = userIds[0]
|
|
|
|
|
const partnerB = userIds[1]
|
|
|
|
|
|
|
|
|
|
const userA = await db.collection('users').doc(partnerA).get()
|
|
|
|
|
const userB = await db.collection('users').doc(partnerB).get()
|
2026-06-30 02:38:31 -05:00
|
|
|
// displayName is E2EE in users/{uid}, so the OS-rendered push uses a generic label; the app shows
|
|
|
|
|
// the real name in-app (resolved locally). Avatar (photoUrl) stays plaintext and is still sent.
|
|
|
|
|
const partnerAName = 'Your partner'
|
|
|
|
|
const partnerBName = 'Your partner'
|
2026-06-23 18:23:49 -05:00
|
|
|
const avatarA = userA.data()?.photoUrl as string | undefined
|
|
|
|
|
const avatarB = userB.data()?.photoUrl as string | undefined
|
2026-06-28 10:00:25 -05:00
|
|
|
// M-001: per-recipient quiet-hours lookup ("no notifications" promise). Fail-open.
|
|
|
|
|
const dataFor = (uid: string) => (uid === partnerA ? userA.data() : userB.data())
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
const currentData = change.after.data() ?? {}
|
2026-06-26 20:04:05 -05:00
|
|
|
const status = currentData.status as string | undefined
|
|
|
|
|
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// 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).
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// ── 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
|
2026-06-28 10:00:25 -05:00
|
|
|
if (recipientInQuietHours(dataFor(recipientId))) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.log(`[onGameSessionUpdate] recipient ${recipientId} in quiet hours — suppressing start push`)
|
2026-06-28 10:00:25 -05:00
|
|
|
} else {
|
|
|
|
|
await notifyPartner(
|
|
|
|
|
db, messaging, recipientId, starterName, gameType,
|
|
|
|
|
'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId,
|
|
|
|
|
starterAvatar, sessionId
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-26 20:04:05 -05:00
|
|
|
}
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 22:24:46 -05:00
|
|
|
// ── Partner joined an active session ─────────────────────────────────
|
|
|
|
|
// The non-starter opening the session writes their uid into `joinedByUsers` (client, rule-gated).
|
|
|
|
|
// Notify the STARTER once, with the joiner's name + avatar. One-time via `joinNotifiedAt`
|
|
|
|
|
// (server-only flag, claimed in a transaction — same pattern as start/finishNotifiedAt).
|
|
|
|
|
if (status === 'active' && !currentData.joinNotifiedAt && Array.isArray(currentData.joinedByUsers)) {
|
|
|
|
|
const startedBy = currentData.startedByUserId
|
|
|
|
|
const joiner = (currentData.joinedByUsers as string[]).find((u) => u && u !== startedBy)
|
|
|
|
|
if (joiner) {
|
|
|
|
|
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.joinNotifiedAt) return false
|
|
|
|
|
const j = Array.isArray(d.joinedByUsers) ? (d.joinedByUsers as string[]) : []
|
|
|
|
|
if (!j.some((u) => u && u !== d.startedByUserId)) return false
|
|
|
|
|
tx.update(sessionRef, { joinNotifiedAt: admin.firestore.FieldValue.serverTimestamp() })
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
if (claimed) {
|
|
|
|
|
const gameType = currentData.gameType ?? 'wheel'
|
|
|
|
|
const joinerName = joiner === partnerA ? partnerAName : partnerBName
|
|
|
|
|
const joinerAvatar = joiner === partnerA ? avatarA : avatarB
|
|
|
|
|
if (recipientInQuietHours(dataFor(startedBy))) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.log(`[onGameSessionUpdate] starter ${startedBy} in quiet hours — suppressing join push`)
|
2026-06-28 22:24:46 -05:00
|
|
|
} else {
|
|
|
|
|
await notifyPartner(
|
|
|
|
|
db, messaging, startedBy, joinerName, gameType,
|
|
|
|
|
'partner_joined_game', `${joinerName} joined your game — tap to play together.`, coupleId,
|
|
|
|
|
joinerAvatar, sessionId
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-26 20:04:05 -05:00
|
|
|
// ── Session completed (reveal ready for both) ────────────────────────
|
2026-07-07 21:03:22 -05:00
|
|
|
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
|
|
|
|
|
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
|
|
|
|
|
// also lands on 'completed' (the abandon write) but with an empty list — pushing
|
|
|
|
|
// "finished — see your results!" for those was a false notification into an empty reveal.
|
|
|
|
|
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : []
|
|
|
|
|
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
|
2026-06-26 20:04:05 -05:00
|
|
|
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'
|
2026-06-28 10:00:25 -05:00
|
|
|
// Notify BOTH partners, each naming the OTHER. M-001: skip a recipient in quiet hours.
|
|
|
|
|
if (recipientInQuietHours(dataFor(partnerA))) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.log(`[onGameSessionUpdate] ${partnerA} in quiet hours — suppressing finish push`)
|
2026-06-28 10:00:25 -05:00
|
|
|
} else {
|
|
|
|
|
await notifyPartner(
|
|
|
|
|
db, messaging, partnerA, partnerBName, gt,
|
|
|
|
|
'partner_finished_game', `${partnerBName} finished — tap to see your results!`, coupleId,
|
|
|
|
|
avatarB, sessionId
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (recipientInQuietHours(dataFor(partnerB))) {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
logger.log(`[onGameSessionUpdate] ${partnerB} in quiet hours — suppressing finish push`)
|
2026-06-28 10:00:25 -05:00
|
|
|
} else {
|
|
|
|
|
await notifyPartner(
|
|
|
|
|
db, messaging, partnerB, partnerAName, gt,
|
|
|
|
|
'partner_finished_game', `${partnerAName} finished — tap to see your results!`, coupleId,
|
|
|
|
|
avatarA, sessionId
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-26 20:04:05 -05:00
|
|
|
}
|
2026-06-18 00:56:21 -05:00
|
|
|
return
|
|
|
|
|
}
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
}
|
|
|
|
|
)
|
2026-06-18 00:56:21 -05:00
|
|
|
|
2026-06-27 13:31:09 -05:00
|
|
|
/**
|
|
|
|
|
* Notify the WAITING partner the moment the FIRST player finishes their part of an async game.
|
|
|
|
|
*
|
|
|
|
|
* Async games (this_or_that / wheel / how_well / desire_sync) write each player's answers to
|
|
|
|
|
* couples/{coupleId}/{gameType}/{sessionId}.answers[uid]; the SESSION doc only flips to
|
|
|
|
|
* 'completed' once BOTH have answered (which onGameSessionUpdate turns into partner_finished_game).
|
|
|
|
|
* Between first-finish and both-finish the waiting partner got NOTHING — they never learned it was
|
|
|
|
|
* their turn (the symptom: "X finished a game but the partner was never notified"). The
|
|
|
|
|
* 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).
|
2026-07-08 00:08:18 -05:00
|
|
|
*
|
|
|
|
|
* 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.
|
2026-06-27 13:31:09 -05:00
|
|
|
*/
|
2026-07-08 00:08:18 -05:00
|
|
|
async function handleGamePartFinished(
|
|
|
|
|
coupleId: string,
|
|
|
|
|
gameType: string,
|
|
|
|
|
sessionId: string,
|
|
|
|
|
change: Change<admin.firestore.DocumentSnapshot> | undefined
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!change?.after.exists) return
|
2026-06-27 13:31:09 -05:00
|
|
|
|
2026-07-08 00:08:18 -05:00
|
|
|
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]
|
2026-06-27 13:31:09 -05:00
|
|
|
|
2026-07-08 00:08:18 -05:00
|
|
|
const db = admin.firestore()
|
|
|
|
|
const messaging = admin.messaging()
|
|
|
|
|
const sessionRef = db.collection('couples').doc(coupleId).collection('sessions').doc(sessionId)
|
2026-06-27 13:31:09 -05:00
|
|
|
|
2026-07-08 00:08:18 -05:00
|
|
|
// 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
|
2026-06-27 13:31:09 -05:00
|
|
|
|
2026-07-08 00:08:18 -05:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
)
|
2026-06-27 13:31:09 -05:00
|
|
|
|
2026-07-07 21:50:39 -05:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
function yourTurnBody(gameType: string): string {
|
|
|
|
|
switch (gameType) {
|
|
|
|
|
case 'how_well': return 'Your turn — guess their answers'
|
|
|
|
|
case 'desire_sync': return 'Your turn — only mutual yeses ever show'
|
|
|
|
|
case 'this_or_that': return 'Your turn — see where you line up'
|
|
|
|
|
default: return 'Your turn — reveal how you line up'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 00:56:21 -05:00
|
|
|
/**
|
|
|
|
|
* Send notification to partner via FCM and write to notification_queue.
|
|
|
|
|
*/
|
|
|
|
|
async function notifyPartner(
|
|
|
|
|
db: admin.firestore.Firestore,
|
|
|
|
|
messaging: admin.messaging.Messaging,
|
|
|
|
|
partnerId: string,
|
|
|
|
|
partnerName: string,
|
|
|
|
|
gameType: string,
|
|
|
|
|
notificationType: string,
|
2026-06-22 08:53:23 -05:00
|
|
|
body: string,
|
2026-06-23 18:23:49 -05:00
|
|
|
coupleId: string,
|
2026-06-25 12:34:43 -05:00
|
|
|
senderAvatarUrl?: string,
|
|
|
|
|
sessionId?: string
|
2026-06-18 00:56:21 -05:00
|
|
|
): Promise<void> {
|
2026-06-24 11:47:49 -05:00
|
|
|
const title =
|
|
|
|
|
notificationType === 'partner_finished_game'
|
|
|
|
|
? `${partnerName} finished the game`
|
2026-06-27 13:31:09 -05:00
|
|
|
: notificationType === 'partner_completed_part'
|
|
|
|
|
? `${partnerName} finished their part`
|
2026-06-28 22:24:46 -05:00
|
|
|
: notificationType === 'partner_joined_game'
|
|
|
|
|
? `${partnerName} joined your game`
|
|
|
|
|
: `${partnerName} is playing`
|
2026-06-18 00:56:21 -05:00
|
|
|
|
|
|
|
|
// Write an in-app notification record for the partner
|
|
|
|
|
await db
|
|
|
|
|
.collection('users')
|
|
|
|
|
.doc(partnerId)
|
|
|
|
|
.collection('notification_queue')
|
|
|
|
|
.add({
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
type: notificationType,
|
|
|
|
|
title,
|
|
|
|
|
body,
|
2026-06-18 00:56:21 -05:00
|
|
|
read: false,
|
|
|
|
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
|
|
|
|
})
|
|
|
|
|
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
await sendPushToUser(db, messaging, partnerId, {
|
|
|
|
|
notification: { title, body },
|
2026-06-25 12:34:43 -05:00
|
|
|
// Put backgrounded notifications on the Games channel instead of the FCM fallback channel,
|
|
|
|
|
// so importance/sound and the per-category toggle apply. E-OBS.
|
|
|
|
|
android: { notification: { channelId: 'game_activity' } },
|
2026-06-18 00:56:21 -05:00
|
|
|
data: {
|
refactor(functions): B1 migrate 13 Firestore triggers to v2 + harden
Migrate all Firestore triggers off the v1 API to firebase-functions/v2/firestore
(onDocumentCreated/Updated/Written); context.params→event.params, snap→event.data,
change.before/after→event.data.before/after. Region stays us-central1 (global option).
Hardening folded in (all reuse in-repo patterns):
- Adopt the shared sendPushToUser()/getUserTokens() helper in every trigger, removing
~7 copied token readers and the copied send/prune blocks. FCM tokens are no longer
logged in plaintext anywhere here (redacted inside push.ts).
- console.* → firebase-functions/logger (structured).
- Idempotency: new claimOnce() (atomic create-if-absent marker under
couples/{id}/notif_marks) dedupes at-least-once redelivery on the non-idempotent
senders (onAnswerWritten/Revealed, onMessageWritten, onCoupleLeave, onEntitlementChanged,
onDateReflectionWritten/Revealed, onDateHistoryCreated). Fail-open. onGameSessionUpdate/
onGamePartFinished/notifyOnDateMatch already had transactional claim-flags — preserved.
- onRestoreRequested: the plan's "claim" is implemented as the existing 60s time-WINDOW
(lastRestorePartnerAlertAt), not a permanent recipientUid marker — a permanent marker
would wrongly block legitimate re-requests (restore docs are deleted+recreated by design).
Faithful port of onGameSessionUpdate/onGamePartFinished (broad wildcard + allowlist kept);
the trigger split and read reorder are deferred to B6 as separate commits.
Build clean; 70 tests green (+ new idempotency.test.ts, push token/prune tests). Emulator
discovery loads all triggers as v2 in us-central1. firebase-functions still v5.1.1 (v6 bump
deferred to B6). dist rebuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:46:17 -05:00
|
|
|
type: notificationType,
|
2026-06-22 08:53:23 -05:00
|
|
|
couple_id: coupleId,
|
|
|
|
|
game_type: gameType,
|
2026-06-28 22:24:46 -05:00
|
|
|
// The acting partner's display name (public; also in the title) so the in-app foreground
|
|
|
|
|
// banner can name them instead of a generic "Your partner".
|
|
|
|
|
sender_name: partnerName,
|
2026-06-25 12:34:43 -05:00
|
|
|
// Lets the client deep link a results-ready push to the per-session results/replay screen
|
|
|
|
|
// (a completed session isn't returned by getActiveSession). E-003 results-ready.
|
|
|
|
|
...(sessionId ? { game_session_id: sessionId } : {}),
|
2026-06-23 18:23:49 -05:00
|
|
|
...(senderAvatarUrl && senderAvatarUrl.length > 0
|
|
|
|
|
? { sender_avatar_url: senderAvatarUrl }
|
|
|
|
|
: {}),
|
2026-06-18 00:56:21 -05:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|