feat: add FCM game retention notification functions, messaging service updates, ThisOrThat screen fixes
This commit is contained in:
parent
70e7c66cd6
commit
0e0a33a6dd
|
|
@ -81,6 +81,7 @@ class AppMessagingService : FirebaseMessagingService() {
|
|||
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
|
||||
"partner_left" -> NotificationHelper.CHANNEL_PARTNER
|
||||
"partner_started_game", "partner_finished_game", "partner_waiting" -> NotificationHelper.CHANNEL_PARTNER
|
||||
"memory_capsule_unlocked", "challenge_day_ready" -> NotificationHelper.CHANNEL_REMINDERS
|
||||
"daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS
|
||||
else -> NotificationHelper.CHANNEL_REMINDERS
|
||||
}
|
||||
|
|
@ -104,6 +105,8 @@ class AppMessagingService : FirebaseMessagingService() {
|
|||
"partner_started_game" -> "Partner is playing!"
|
||||
"partner_finished_game" -> "Partner finished!"
|
||||
"partner_waiting" -> "Partner waiting"
|
||||
"memory_capsule_unlocked" -> "Your memory capsule opened"
|
||||
"challenge_day_ready" -> "Today's challenge is ready"
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
|
@ -115,6 +118,8 @@ class AppMessagingService : FirebaseMessagingService() {
|
|||
"partner_started_game" -> "Your partner has started a game. Tap to join!"
|
||||
"partner_finished_game" -> "Your partner has finished. Tap to see results!"
|
||||
"partner_waiting" -> "Your partner is waiting for you to finish."
|
||||
"memory_capsule_unlocked" -> "Open Memory Lane to read it together."
|
||||
"challenge_day_ready" -> "Open your connection challenge for today's prompt."
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,34 @@ import kotlinx.coroutines.launch
|
|||
|
||||
// ── ViewModel ────────────────────────────────────────────────────────────────
|
||||
|
||||
enum class TotPhase { LOADING, PLAYING, WAITING, REVEAL, ERROR }
|
||||
enum class TotPhase { LOADING, PICK_MOOD, PLAYING, WAITING, REVEAL, ERROR }
|
||||
|
||||
enum class TotMood(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val categoryIds: Set<String>?
|
||||
) {
|
||||
LIGHT(
|
||||
title = "Light",
|
||||
subtitle = "Playful, warm, easy picks",
|
||||
categoryIds = setOf("fun", "date_night", "gratitude", "home_life")
|
||||
),
|
||||
EVERYDAY(
|
||||
title = "Everyday",
|
||||
subtitle = "Home, values, plans, small rhythms",
|
||||
categoryIds = setOf("communication", "home_life", "values", "future", "money")
|
||||
),
|
||||
DEEP(
|
||||
title = "Deep",
|
||||
subtitle = "Trust, repair, commitment, closeness",
|
||||
categoryIds = setOf("emotional_intimacy", "trust", "conflict_repair", "difficult_conversations", "marriage")
|
||||
),
|
||||
ALL(
|
||||
title = "All topics",
|
||||
subtitle = "Shuffle the whole question bank",
|
||||
categoryIds = null
|
||||
)
|
||||
}
|
||||
|
||||
/** One prompt's outcome: what each partner picked and whether they matched. */
|
||||
data class RevealCard(
|
||||
|
|
@ -156,16 +183,27 @@ class ThisOrThatViewModel @Inject constructor(
|
|||
// A different game is already in progress — respect the one-game lock.
|
||||
_uiState.update { it.copy(navigateTo = AppRoute.WAITING_FOR_PARTNER) }
|
||||
else ->
|
||||
createSession(uid)
|
||||
_uiState.update { it.copy(phase = TotPhase.PICK_MOOD) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseMood(mood: TotMood) {
|
||||
val uid = userId ?: return fail("You need to be signed in to play.")
|
||||
_uiState.update { it.copy(phase = TotPhase.LOADING, error = null) }
|
||||
viewModelScope.launch { createSession(uid, mood) }
|
||||
}
|
||||
|
||||
/** First partner: pick a fixed set of prompts and open the shared session. */
|
||||
private suspend fun createSession(uid: String) {
|
||||
val picked = runCatching { repository.getQuestionsByType("this_or_that") }
|
||||
private suspend fun createSession(uid: String, mood: TotMood) {
|
||||
val allQuestions = runCatching { repository.getQuestionsByType("this_or_that") }
|
||||
.onFailure { Log.w(TAG, "Failed to load this_or_that questions", it) }
|
||||
.getOrElse { emptyList() }
|
||||
val moodQuestions = mood.categoryIds
|
||||
?.let { categoryIds -> allQuestions.filter { it.category in categoryIds } }
|
||||
?: allQuestions
|
||||
val pool = moodQuestions.takeIf { it.size >= SESSION_SIZE } ?: allQuestions
|
||||
val picked = pool
|
||||
.shuffled()
|
||||
.take(SESSION_SIZE)
|
||||
if (picked.isEmpty()) return fail("No questions available.")
|
||||
|
|
@ -377,6 +415,10 @@ fun ThisOrThatScreen(
|
|||
onBack = viewModel::quit,
|
||||
onAbandon = viewModel::abandon
|
||||
)
|
||||
TotPhase.PICK_MOOD -> ThisOrThatMoodPicker(
|
||||
onMoodSelected = viewModel::chooseMood,
|
||||
onBack = viewModel::quit
|
||||
)
|
||||
TotPhase.REVEAL -> ThisOrThatReveal(
|
||||
matched = state.matchedCount,
|
||||
total = state.revealCards.size,
|
||||
|
|
@ -409,6 +451,101 @@ fun ThisOrThatScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThisOrThatMoodPicker(
|
||||
onMoodSelected: (TotMood) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = PaddingValues(bottom = 20.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Choose a mood",
|
||||
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = "Your partner will join the same set.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onBack) {
|
||||
Text("Back", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(TotMood.values().toList()) { mood ->
|
||||
Card(
|
||||
onClick = { onMoodSelected(mood) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = closerCardColor(alpha = 0.9f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 5.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(44.dp),
|
||||
shape = CircleShape,
|
||||
color = CloserPalette.PurpleMist
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = when (mood) {
|
||||
TotMood.LIGHT -> "1"
|
||||
TotMood.EVERYDAY -> "2"
|
||||
TotMood.DEEP -> "3"
|
||||
TotMood.ALL -> "All"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = CloserPalette.PurpleDeep,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = mood.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = mood.subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThisOrThatContent(
|
||||
question: Question,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.health = exports.onGameSessionUpdate = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
|
||||
exports.health = exports.onGameSessionUpdate = exports.onCoupleLeave = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.createDateMatchOnMutualLove = exports.checkDeviceIntegrity = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = 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.
|
||||
|
|
@ -49,6 +49,9 @@ Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: funct
|
|||
var reminders_1 = require("./notifications/reminders");
|
||||
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
|
||||
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } });
|
||||
var gameRetention_1 = require("./notifications/gameRetention");
|
||||
Object.defineProperty(exports, "sendChallengeDayReminders", { enumerable: true, get: function () { return gameRetention_1.sendChallengeDayReminders; } });
|
||||
Object.defineProperty(exports, "unlockDueMemoryCapsules", { enumerable: true, get: function () { return gameRetention_1.unlockDueMemoryCapsules; } });
|
||||
var checkDeviceIntegrity_1 = require("./security/checkDeviceIntegrity");
|
||||
Object.defineProperty(exports, "checkDeviceIntegrity", { enumerable: true, get: function () { return checkDeviceIntegrity_1.checkDeviceIntegrity; } });
|
||||
var createDateMatch_1 = require("./dates/createDateMatch");
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"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;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAAqE;AAA5D,8HAAA,2BAA2B,OAAA;AACpC,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,mEAAiE;AAAxD,0HAAA,mBAAmB,OAAA;AAE5B;;;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,231 @@
|
|||
"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.sendChallengeDayReminders = exports.unlockDueMemoryCapsules = void 0;
|
||||
const functions = __importStar(require("firebase-functions"));
|
||||
const admin = __importStar(require("firebase-admin"));
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const CHALLENGE_TITLES = {
|
||||
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
|
||||
appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 },
|
||||
quality_time: { title: 'Quality Time', durationDays: 7 },
|
||||
deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
|
||||
};
|
||||
exports.unlockDueMemoryCapsules = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
const snapshot = await db
|
||||
.collectionGroup('capsules')
|
||||
.where('status', '==', 'sealed')
|
||||
.where('unlockAt', '<=', now)
|
||||
.limit(100)
|
||||
.get();
|
||||
const notifications = [];
|
||||
for (const capsuleDoc of snapshot.docs) {
|
||||
const capsuleRef = capsuleDoc.ref;
|
||||
const coupleRef = capsuleRef.parent.parent;
|
||||
if (!coupleRef)
|
||||
continue;
|
||||
const capsuleNotifications = await db.runTransaction(async (tx) => {
|
||||
var _a, _b, _c, _d;
|
||||
const freshCapsule = await tx.get(capsuleRef);
|
||||
const capsule = (_a = freshCapsule.data()) !== null && _a !== void 0 ? _a : {};
|
||||
if (capsule.status !== 'sealed' || Number((_b = capsule.unlockAt) !== null && _b !== void 0 ? _b : 0) > now) {
|
||||
return [];
|
||||
}
|
||||
const coupleDoc = await tx.get(coupleRef);
|
||||
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
|
||||
if (userIds.length === 0)
|
||||
return [];
|
||||
tx.update(capsuleRef, {
|
||||
status: 'unlocked',
|
||||
unlockedAt: now,
|
||||
unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const capsuleId = capsuleRef.id;
|
||||
const coupleId = coupleRef.id;
|
||||
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
|
||||
? capsule.title.trim()
|
||||
: 'A memory capsule';
|
||||
return userIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'memory_capsule_unlocked',
|
||||
title: 'Your memory capsule opened',
|
||||
body: `${title} is ready to read together.`,
|
||||
data: { coupleId, capsuleId },
|
||||
}));
|
||||
});
|
||||
notifications.push(...capsuleNotifications);
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)));
|
||||
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`);
|
||||
});
|
||||
exports.sendChallengeDayReminders = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
const db = admin.firestore();
|
||||
const messaging = admin.messaging();
|
||||
const now = Date.now();
|
||||
const snapshot = await db
|
||||
.collectionGroup('challenges')
|
||||
.where('status', '==', 'active')
|
||||
.limit(100)
|
||||
.get();
|
||||
const notifications = [];
|
||||
for (const challengeDoc of snapshot.docs) {
|
||||
const challengeRef = challengeDoc.ref;
|
||||
const coupleRef = challengeRef.parent.parent;
|
||||
if (!coupleRef)
|
||||
continue;
|
||||
const challengeNotifications = await db.runTransaction(async (tx) => {
|
||||
var _a, _b, _c, _d, _e, _f, _g;
|
||||
const freshChallenge = await tx.get(challengeRef);
|
||||
const challenge = (_a = freshChallenge.data()) !== null && _a !== void 0 ? _a : {};
|
||||
if (challenge.status !== 'active')
|
||||
return [];
|
||||
const startedAt = Number((_b = challenge.startedAt) !== null && _b !== void 0 ? _b : 0);
|
||||
if (startedAt <= 0 || startedAt > now)
|
||||
return [];
|
||||
const challengeId = typeof challenge.challengeId === 'string'
|
||||
? challenge.challengeId
|
||||
: challengeRef.id;
|
||||
const catalogEntry = (_c = CHALLENGE_TITLES[challengeId]) !== null && _c !== void 0 ? _c : {
|
||||
title: 'Connection Challenge',
|
||||
durationDays: 7,
|
||||
};
|
||||
const day = Math.floor((now - startedAt) / DAY_MS) + 1;
|
||||
if (day < 1 || day > catalogEntry.durationDays)
|
||||
return [];
|
||||
const coupleDoc = await tx.get(coupleRef);
|
||||
const userIds = ((_e = (_d = coupleDoc.data()) === null || _d === void 0 ? void 0 : _d.userIds) !== null && _e !== void 0 ? _e : []);
|
||||
if (userIds.length === 0)
|
||||
return [];
|
||||
const completions = ((_f = challenge.completions) !== null && _f !== void 0 ? _f : {});
|
||||
const reminderSent = ((_g = challenge.challengeReminderSent) !== null && _g !== void 0 ? _g : {});
|
||||
const dueUserIds = userIds.filter((userId) => {
|
||||
var _a;
|
||||
const completedDays = (_a = completions[userId]) !== null && _a !== void 0 ? _a : [];
|
||||
const alreadyCompleted = completedDays.map(Number).includes(day);
|
||||
const alreadySent = reminderSent[reminderKey(userId, day)] === true;
|
||||
return !alreadyCompleted && !alreadySent;
|
||||
});
|
||||
if (dueUserIds.length === 0)
|
||||
return [];
|
||||
const updates = {
|
||||
lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
};
|
||||
dueUserIds.forEach((userId) => {
|
||||
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true;
|
||||
});
|
||||
tx.update(challengeRef, updates);
|
||||
const coupleId = coupleRef.id;
|
||||
return dueUserIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'challenge_day_ready',
|
||||
title: `Day ${day} is ready`,
|
||||
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
|
||||
data: { coupleId, challengeId, day: String(day) },
|
||||
}));
|
||||
});
|
||||
notifications.push(...challengeNotifications);
|
||||
}
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)));
|
||||
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`);
|
||||
});
|
||||
function reminderKey(userId, day) {
|
||||
return `${userId.replace(/[^\w-]/g, '_')}_${day}`;
|
||||
}
|
||||
async function sendNotification(db, messaging, notification) {
|
||||
await db
|
||||
.collection('users')
|
||||
.doc(notification.userId)
|
||||
.collection('notification_queue')
|
||||
.add({
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
const tokens = await getUserTokens(db, notification.userId);
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`);
|
||||
return;
|
||||
}
|
||||
const message = {
|
||||
token: tokens[0],
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
data: Object.assign({ type: notification.type }, notification.data),
|
||||
};
|
||||
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send(Object.assign(Object.assign({}, message), { token }))));
|
||||
const failures = [];
|
||||
sendResults.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
failures.push(`${tokens[index]}: ${String(result.reason)}`);
|
||||
}
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
console.error(`[sendNotification] some notifications failed:`, failures);
|
||||
}
|
||||
}
|
||||
async function getUserTokens(db, userId) {
|
||||
var _a;
|
||||
const tokens = [];
|
||||
const userDoc = await db.collection('users').doc(userId).get();
|
||||
const legacyToken = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken);
|
||||
}
|
||||
const tokenSnapshot = await db
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.collection('fcmTokens')
|
||||
.get();
|
||||
tokenSnapshot.docs.forEach((doc) => {
|
||||
var _a;
|
||||
const token = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
|
||||
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
|
||||
tokens.push(token);
|
||||
}
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
//# sourceMappingURL=gameRetention.js.map
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -14,6 +14,10 @@ export {
|
|||
sendDailyQuestionReminder,
|
||||
sendPartnerAnsweredNotification,
|
||||
} from './notifications/reminders'
|
||||
export {
|
||||
sendChallengeDayReminders,
|
||||
unlockDueMemoryCapsules,
|
||||
} from './notifications/gameRetention'
|
||||
export { checkDeviceIntegrity } from './security/checkDeviceIntegrity'
|
||||
export { createDateMatchOnMutualLove } from './dates/createDateMatch'
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const CHALLENGE_TITLES: Record<string, { title: string; durationDays: number }> = {
|
||||
gratitude_week: { title: 'Gratitude Week', durationDays: 7 },
|
||||
appreciation_notes: { title: 'Appreciation Notes', durationDays: 7 },
|
||||
quality_time: { title: 'Quality Time', durationDays: 7 },
|
||||
deep_conversations: { title: 'Deep Conversations', durationDays: 7 },
|
||||
}
|
||||
|
||||
type NotificationType = 'memory_capsule_unlocked' | 'challenge_day_ready'
|
||||
|
||||
interface QueuedNotification {
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
body: string
|
||||
data: Record<string, string>
|
||||
}
|
||||
|
||||
export const unlockDueMemoryCapsules = functions.pubsub
|
||||
.schedule('every 1 hours')
|
||||
.onRun(async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
||||
const snapshot = await db
|
||||
.collectionGroup('capsules')
|
||||
.where('status', '==', 'sealed')
|
||||
.where('unlockAt', '<=', now)
|
||||
.limit(100)
|
||||
.get()
|
||||
|
||||
const notifications: QueuedNotification[] = []
|
||||
|
||||
for (const capsuleDoc of snapshot.docs) {
|
||||
const capsuleRef = capsuleDoc.ref
|
||||
const coupleRef = capsuleRef.parent.parent
|
||||
if (!coupleRef) continue
|
||||
|
||||
const capsuleNotifications = await db.runTransaction(async (tx) => {
|
||||
const freshCapsule = await tx.get(capsuleRef)
|
||||
const capsule = freshCapsule.data() ?? {}
|
||||
if (capsule.status !== 'sealed' || Number(capsule.unlockAt ?? 0) > now) {
|
||||
return [] as QueuedNotification[]
|
||||
}
|
||||
|
||||
const coupleDoc = await tx.get(coupleRef)
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
if (userIds.length === 0) return [] as QueuedNotification[]
|
||||
|
||||
tx.update(capsuleRef, {
|
||||
status: 'unlocked',
|
||||
unlockedAt: now,
|
||||
unlockNotifiedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const capsuleId = capsuleRef.id
|
||||
const coupleId = coupleRef.id
|
||||
const title = typeof capsule.title === 'string' && capsule.title.trim().length > 0
|
||||
? capsule.title.trim()
|
||||
: 'A memory capsule'
|
||||
|
||||
return userIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'memory_capsule_unlocked' as const,
|
||||
title: 'Your memory capsule opened',
|
||||
body: `${title} is ready to read together.`,
|
||||
data: { coupleId, capsuleId },
|
||||
}))
|
||||
})
|
||||
|
||||
notifications.push(...capsuleNotifications)
|
||||
}
|
||||
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
|
||||
console.log(`[unlockDueMemoryCapsules] unlocked ${snapshot.size}; notified ${notifications.length}`)
|
||||
})
|
||||
|
||||
export const sendChallengeDayReminders = functions.pubsub
|
||||
.schedule('every 24 hours')
|
||||
.onRun(async () => {
|
||||
const db = admin.firestore()
|
||||
const messaging = admin.messaging()
|
||||
const now = Date.now()
|
||||
|
||||
const snapshot = await db
|
||||
.collectionGroup('challenges')
|
||||
.where('status', '==', 'active')
|
||||
.limit(100)
|
||||
.get()
|
||||
|
||||
const notifications: QueuedNotification[] = []
|
||||
|
||||
for (const challengeDoc of snapshot.docs) {
|
||||
const challengeRef = challengeDoc.ref
|
||||
const coupleRef = challengeRef.parent.parent
|
||||
if (!coupleRef) continue
|
||||
|
||||
const challengeNotifications = await db.runTransaction(async (tx) => {
|
||||
const freshChallenge = await tx.get(challengeRef)
|
||||
const challenge = freshChallenge.data() ?? {}
|
||||
if (challenge.status !== 'active') return [] as QueuedNotification[]
|
||||
|
||||
const startedAt = Number(challenge.startedAt ?? 0)
|
||||
if (startedAt <= 0 || startedAt > now) return [] as QueuedNotification[]
|
||||
|
||||
const challengeId = typeof challenge.challengeId === 'string'
|
||||
? challenge.challengeId
|
||||
: challengeRef.id
|
||||
const catalogEntry = CHALLENGE_TITLES[challengeId] ?? {
|
||||
title: 'Connection Challenge',
|
||||
durationDays: 7,
|
||||
}
|
||||
const day = Math.floor((now - startedAt) / DAY_MS) + 1
|
||||
if (day < 1 || day > catalogEntry.durationDays) return [] as QueuedNotification[]
|
||||
|
||||
const coupleDoc = await tx.get(coupleRef)
|
||||
const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
|
||||
if (userIds.length === 0) return [] as QueuedNotification[]
|
||||
|
||||
const completions = (challenge.completions ?? {}) as Record<string, number[]>
|
||||
const reminderSent = (challenge.challengeReminderSent ?? {}) as Record<string, boolean>
|
||||
const dueUserIds = userIds.filter((userId) => {
|
||||
const completedDays = completions[userId] ?? []
|
||||
const alreadyCompleted = completedDays.map(Number).includes(day)
|
||||
const alreadySent = reminderSent[reminderKey(userId, day)] === true
|
||||
return !alreadyCompleted && !alreadySent
|
||||
})
|
||||
|
||||
if (dueUserIds.length === 0) return [] as QueuedNotification[]
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
lastChallengeReminderAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
}
|
||||
dueUserIds.forEach((userId) => {
|
||||
updates[`challengeReminderSent.${reminderKey(userId, day)}`] = true
|
||||
})
|
||||
tx.update(challengeRef, updates)
|
||||
|
||||
const coupleId = coupleRef.id
|
||||
return dueUserIds.map((userId) => ({
|
||||
userId,
|
||||
type: 'challenge_day_ready' as const,
|
||||
title: `Day ${day} is ready`,
|
||||
body: `${catalogEntry.title}: today's connection prompt is waiting.`,
|
||||
data: { coupleId, challengeId, day: String(day) },
|
||||
}))
|
||||
})
|
||||
|
||||
notifications.push(...challengeNotifications)
|
||||
}
|
||||
|
||||
await Promise.all(notifications.map((notification) => sendNotification(db, messaging, notification)))
|
||||
console.log(`[sendChallengeDayReminders] scanned ${snapshot.size}; notified ${notifications.length}`)
|
||||
})
|
||||
|
||||
function reminderKey(userId: string, day: number): string {
|
||||
return `${userId.replace(/[^\w-]/g, '_')}_${day}`
|
||||
}
|
||||
|
||||
async function sendNotification(
|
||||
db: admin.firestore.Firestore,
|
||||
messaging: admin.messaging.Messaging,
|
||||
notification: QueuedNotification
|
||||
): Promise<void> {
|
||||
await db
|
||||
.collection('users')
|
||||
.doc(notification.userId)
|
||||
.collection('notification_queue')
|
||||
.add({
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
read: false,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
})
|
||||
|
||||
const tokens = await getUserTokens(db, notification.userId)
|
||||
if (tokens.length === 0) {
|
||||
console.log(`[sendNotification] no FCM tokens for ${notification.userId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const message: admin.messaging.Message = {
|
||||
token: tokens[0],
|
||||
notification: {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
},
|
||||
data: {
|
||||
type: notification.type,
|
||||
...notification.data,
|
||||
},
|
||||
}
|
||||
|
||||
const sendResults = await Promise.allSettled(
|
||||
tokens.map((token) => messaging.send({ ...message, token }))
|
||||
)
|
||||
|
||||
const failures: string[] = []
|
||||
sendResults.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
failures.push(`${tokens[index]}: ${String(result.reason)}`)
|
||||
}
|
||||
})
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`[sendNotification] some notifications failed:`, failures)
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserTokens(db: admin.firestore.Firestore, userId: string): Promise<string[]> {
|
||||
const tokens: string[] = []
|
||||
const userDoc = await db.collection('users').doc(userId).get()
|
||||
const legacyToken = userDoc.data()?.fcmToken
|
||||
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
|
||||
tokens.push(legacyToken)
|
||||
}
|
||||
|
||||
const tokenSnapshot = await db
|
||||
.collection('users')
|
||||
.doc(userId)
|
||||
.collection('fcmTokens')
|
||||
.get()
|
||||
|
||||
tokenSnapshot.docs.forEach((doc) => {
|
||||
const token = doc.data()?.token
|
||||
if (typeof token === 'string' && token.length > 0 && !tokens.includes(token)) {
|
||||
tokens.push(token)
|
||||
}
|
||||
})
|
||||
|
||||
return tokens
|
||||
}
|
||||
Loading…
Reference in New Issue