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 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 { 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 { 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 }