feat: cloud function for date match cleanup, ViewModel and repo improvements

This commit is contained in:
null 2026-06-17 20:26:24 -05:00
parent fb87fe149a
commit c816033e74
8 changed files with 203 additions and 38 deletions

View File

@ -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? {

View File

@ -90,12 +90,25 @@ class DateMatchViewModel @Inject constructor(
val matchesFlow: Flow<List<DateMatch>> = 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<String>? = 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 {

88
functions/dist/dates/createDateMatch.js vendored Normal file
View File

@ -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

View File

@ -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"}

View File

@ -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.

View File

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

View File

@ -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<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 // already matched — no-op
tx.set(matchRef, {
dateIdeaId,
matchedBy: lovedBy,
revealedAt: admin.firestore.FieldValue.serverTimestamp(),
})
})
})

View File

@ -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.