feat: cloud function for date match cleanup, ViewModel and repo improvements
This commit is contained in:
parent
fb87fe149a
commit
c816033e74
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue