diff --git a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt index ef5af7d3..8c0ff342 100644 --- a/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/DateMatchRepositoryImpl.kt @@ -9,7 +9,6 @@ import app.closer.domain.model.SwipeAction import app.closer.domain.repository.DateMatchRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -20,9 +19,12 @@ import javax.inject.Singleton * shipped as a Cloud Function seed. Firestore is the source of truth for * per-partner swipes and revealed matches. * - * Mutual match detection happens client-side after recording a swipe: if the - * current user swiped [SwipeAction.LOVE] and the partner also has a love swipe - * on the same date idea, a revealed match is created. + * Mutual match detection is performed server-side by the + * `createDateMatchOnMutualLove` Cloud Function: the `date_matches` collection is + * server-write-only (Firestore rules deny client writes), so when both partners + * swipe [SwipeAction.LOVE] on an idea the function creates the match and it + * arrives on the client via [observeMatches]. [recordSwipe] therefore only + * records the swipe and always resolves with a null match. */ @Singleton class DateMatchRepositoryImpl @Inject constructor( @@ -45,25 +47,8 @@ class DateMatchRepositoryImpl @Inject constructor( swipedAt = System.currentTimeMillis() ) swipeDataSource.recordSwipe(coupleId, swipe) - - if (action != SwipeAction.LOVE) return@runCatching null - - val existing = matchDataSource.findMatchByDateIdeaId(coupleId, dateIdeaId) - if (existing != null) return@runCatching existing - - val allSwipes = swipeDataSource.getAllSwipesForDate(coupleId, dateIdeaId) - val partnerSwipe = allSwipes.firstOrNull { it.userId != userId && it.action == SwipeAction.LOVE } - ?: return@runCatching null - - val matchedBy = listOf(userId, partnerSwipe.userId).distinct().sorted() - val matchId = matchDataSource.createMatch(coupleId, dateIdeaId, matchedBy) - DateMatch( - id = matchId, - coupleId = coupleId, - dateIdeaId = dateIdeaId, - revealedAt = System.currentTimeMillis(), - matchedBy = matchedBy - ) + // Match creation is server-side (see class docs); surfaced via observeMatches. + null } override suspend fun getOwnSwipe(coupleId: String, userId: String, dateIdeaId: String): DateSwipe? { diff --git a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt index a90eab30..626addaa 100644 --- a/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt +++ b/app/src/main/java/app/closer/ui/dates/DateMatchViewModel.kt @@ -90,12 +90,25 @@ class DateMatchViewModel @Inject constructor( val matchesFlow: Flow> = repository.observeMatches(coupleId) viewModelScope.launch { - combine(swipesFlow, matchesFlow) { swipes, matches -> - _uiState.value.copy( - ownSwipes = swipes.associate { it.dateIdeaId to it.action }, - matches = matches - ) - }.collect { _uiState.value = it } + // Matches are created server-side and stream in via observeMatches. + // Track ids already seen so the "It's a match!" celebration only fires + // for matches created after this screen started observing — not for + // matches that already existed on load. + var knownMatchIds: Set? = null + combine(swipesFlow, matchesFlow) { swipes, matches -> swipes to matches } + .collect { (swipes, matches) -> + val newMatch = knownMatchIds?.let { known -> + matches.firstOrNull { it.id !in known } + } + knownMatchIds = matches.mapTo(mutableSetOf()) { it.id } + _uiState.update { state -> + state.copy( + ownSwipes = swipes.associate { it.dateIdeaId to it.action }, + matches = matches, + justMatched = newMatch ?: state.justMatched + ) + } + } } } @@ -108,13 +121,11 @@ class DateMatchViewModel @Inject constructor( _uiState.update { it.copy(justMatched = null) } val result = repository.recordSwipe(coupleId, userId, current.id, action) result.fold( - onSuccess = { match -> - _uiState.update { - it.copy( - currentIndex = it.currentIndex + 1, - justMatched = match - ) - } + onSuccess = { + // Advance to the next idea. A resulting match (if both partners + // loved this idea) is created server-side and surfaces via the + // observeMatches flow, which sets justMatched. + _uiState.update { it.copy(currentIndex = it.currentIndex + 1) } }, onFailure = { err -> _uiState.update { diff --git a/functions/dist/dates/createDateMatch.js b/functions/dist/dates/createDateMatch.js new file mode 100644 index 00000000..748fe97d --- /dev/null +++ b/functions/dist/dates/createDateMatch.js @@ -0,0 +1,88 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createDateMatchOnMutualLove = void 0; +const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +const LOVE = 'love'; +/** + * 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. + */ +exports.createDateMatchOnMutualLove = functions.firestore + .document('couples/{coupleId}/date_swipes/{dateIdeaId}') + .onWrite(async (change, context) => { + var _a; + const after = change.after.data(); + if (!after) + return; // swipe document was deleted + const actions = ((_a = after.actions) !== null && _a !== void 0 ? _a : {}); + const lovedBy = Object.entries(actions) + .filter(([, entry]) => (entry === null || entry === void 0 ? void 0 : 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; + 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; // already matched — no-op + tx.set(matchRef, { + dateIdeaId, + matchedBy: lovedBy, + revealedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + }); +}); +//# sourceMappingURL=createDateMatch.js.map \ No newline at end of file diff --git a/functions/dist/dates/createDateMatch.js.map b/functions/dist/dates/createDateMatch.js.map new file mode 100644 index 00000000..e0641fbc --- /dev/null +++ b/functions/dist/dates/createDateMatch.js.map @@ -0,0 +1 @@ +{"version":3,"file":"createDateMatch.js","sourceRoot":"","sources":["../../src/dates/createDateMatch.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AAEvC,MAAM,IAAI,GAAG,MAAM,CAAA;AAOnB;;;;;;;;;;;;;;GAcG;AACU,QAAA,2BAA2B,GAAG,SAAS,CAAC,SAAS;KAC3D,QAAQ,CAAC,6CAA6C,CAAC;KACvD,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE;;IACjC,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAA;IACjC,IAAI,CAAC,KAAK;QAAE,OAAM,CAAC,6BAA6B;IAEhD,MAAM,OAAO,GAAG,CAAC,MAAA,KAAK,CAAC,OAAO,mCAAI,EAAE,CAA+B,CAAA;IACnE,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC;SACpC,MAAM,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,CAAA,KAAK,aAAL,KAAK,uBAAL,KAAK,CAAE,MAAM,MAAK,IAAI,CAAC;SAC7C,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC;SACnB,IAAI,EAAE,CAAA;IAET,2DAA2D;IAC3D,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,OAAM;IAE9B,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC,MAGxC,CAAA;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAC5B,MAAM,QAAQ,GAAG,EAAE;SAChB,UAAU,CAAC,SAAS,CAAC;SACrB,GAAG,CAAC,QAAQ,CAAC;SACb,UAAU,CAAC,cAAc,CAAC;SAC1B,GAAG,CAAC,UAAU,CAAC,CAAA;IAElB,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACnC,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;QACvC,IAAI,QAAQ,CAAC,MAAM;YAAE,OAAM,CAAC,0BAA0B;QACtD,EAAE,CAAC,GAAG,CAAC,QAAQ,EAAE;YACf,UAAU;YACV,SAAS,EAAE,OAAO;YAClB,UAAU,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;SACzD,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/dist/index.js b/functions/dist/index.js index e739c6fd..9fd4c9db 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -33,8 +33,15 @@ var __importStar = (this && this.__importStar) || (function () { }; })(); Object.defineProperty(exports, "__esModule", { value: true }); -exports.health = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; +exports.health = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0; const functions = __importStar(require("firebase-functions")); +const admin = __importStar(require("firebase-admin")); +// Initialize the Admin SDK once for every function in this codebase. +// Handlers call admin.firestore()/messaging() lazily at invocation time, so a +// single idempotent init here is sufficient and avoids "already exists" errors. +if (admin.apps.length === 0) { + admin.initializeApp(); +} var revenueCatWebhook_1 = require("./billing/revenueCatWebhook"); Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } }); var syncEntitlement_1 = require("./billing/syncEntitlement"); @@ -44,6 +51,8 @@ Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } }); var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity"); Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } }); +var createDateMatch_1 = require("./dates/createDateMatch"); +Object.defineProperty(exports, "createDateMatchOnMutualLove", { enumerable: true, get: function () { return createDateMatch_1.createDateMatchOnMutualLove; } }); /** * Basic health check callable. * Useful for verifying function deployment and firebase-tools wiring. diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index a30d7395..b3bc1ad9 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAE/C,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAE7B;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,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,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AAEpC;;;GAGG;AACU,QAAA,MAAM,GAAG,SAAS,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE;IAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;AACxC,CAAC,CAAC,CAAA"} \ No newline at end of file diff --git a/functions/src/dates/createDateMatch.ts b/functions/src/dates/createDateMatch.ts new file mode 100644 index 00000000..73a2e4b3 --- /dev/null +++ b/functions/src/dates/createDateMatch.ts @@ -0,0 +1,62 @@ +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 // already matched — no-op + tx.set(matchRef, { + dateIdeaId, + matchedBy: lovedBy, + revealedAt: admin.firestore.FieldValue.serverTimestamp(), + }) + }) + }) diff --git a/functions/src/index.ts b/functions/src/index.ts index f63ccd4e..42eb3abc 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,4 +1,12 @@ import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +// Initialize the Admin SDK once for every function in this codebase. +// Handlers call admin.firestore()/messaging() lazily at invocation time, so a +// single idempotent init here is sufficient and avoids "already exists" errors. +if (admin.apps.length === 0) { + admin.initializeApp() +} export { revenueCatWebhook } from './billing/revenueCatWebhook' export { syncEntitlement } from './billing/syncEntitlement' @@ -7,6 +15,7 @@ export { sendPartnerAnsweredNotification, } from './notifications/reminders' export { checkDeviceIntegrity } from './security/checkDeviceIntegrity' +export { createDateMatchOnMutualLove } from './dates/createDateMatch' /** * Basic health check callable.