Closer/functions/src/dates/createDateMatch.ts

130 lines
4.0 KiB
TypeScript
Raw Normal View History

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const LOVE = 'love'
interface SwipeEntry {
action?: string
swipedAt?: number
}
/**
* Creates a revealed date match when both partners have swiped LOVE on the
* same date idea.
*
* Trigger: couples/{coupleId}/date_swipes/{dateIdeaId} (onWrite)
*
* The `date_matches` collection is server-write-only Firestore rules deny all
* client writes (`allow create, update, delete: if false`). This trigger is
* therefore the single source of truth for match creation. The client only
* records swipes and observes `date_matches` for the result.
*
* Idempotency: the match document id is the date idea id and creation runs in a
* transaction, so repeated swipes on the same idea and concurrent invocations
* never produce a duplicate match.
*/
export const createDateMatchOnMutualLove = functions.firestore
.document('couples/{coupleId}/date_swipes/{dateIdeaId}')
.onWrite(async (change, context) => {
const after = change.after.data()
if (!after) return // swipe document was deleted
const actions = (after.actions ?? {}) as Record<string, SwipeEntry>
const lovedBy = Object.entries(actions)
.filter(([, entry]) => entry?.action === LOVE)
.map(([uid]) => uid)
.sort()
// A match needs both partners to have loved the same idea.
if (lovedBy.length < 2) return
const { coupleId, dateIdeaId } = context.params as {
coupleId: string
dateIdeaId: string
}
const db = admin.firestore()
const matchRef = db
.collection('couples')
.doc(coupleId)
.collection('date_matches')
.doc(dateIdeaId)
await db.runTransaction(async (tx) => {
const existing = await tx.get(matchRef)
if (existing.exists) return
tx.set(matchRef, {
dateIdeaId,
matchedBy: lovedBy,
revealedAt: admin.firestore.FieldValue.serverTimestamp(),
fcmNotified: false,
})
})
// Atomically claim FCM send so concurrent trigger invocations don't double-send.
const shouldSend = await db.runTransaction(async (tx) => {
const doc = await tx.get(matchRef)
if (!doc.exists || doc.data()?.fcmNotified === true) return false
tx.update(matchRef, { fcmNotified: true })
return true
})
if (shouldSend) {
const coupleDoc = await db.collection('couples').doc(coupleId).get()
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
await Promise.all(userIds.map((uid) => notifyDateMatch(db, uid, coupleId, dateIdeaId)))
}
})
async function notifyDateMatch(
db: admin.firestore.Firestore,
userId: string,
coupleId: string,
dateIdeaId: string
): Promise<void> {
await db.collection('users').doc(userId).collection('notification_queue').add({
type: 'date_match',
title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.",
read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
const tokens = await getUserTokens(db, userId)
if (tokens.length === 0) return
await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: {
title: "It's a match!",
body: "You both want to go on this date. Time to make it happen.",
},
data: {
type: 'date_match',
couple_id: coupleId,
date_idea_id: dateIdeaId,
},
})
)
)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string
): Promise<string[]> {
const tokens: string[] = []
const userDoc = await db.collection('users').doc(userId).get()
const legacy = userDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get()
snap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
return tokens
}