feat(notifications): FCM token registration, quiet hours, notification permission helper, reminder Cloud Functions (batch 11)

This commit is contained in:
null 2026-06-17 01:30:04 -05:00
parent 6b964935d4
commit f3bad90ec6
16 changed files with 489 additions and 16 deletions

View File

@ -1,5 +1,7 @@
package app.closer.core.notifications
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.messaging.FirebaseMessagingService
@ -9,7 +11,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
@AndroidEntryPoint
@ -19,6 +23,7 @@ class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var userRepository: UserRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val Context.dataStore by preferencesDataStore(name = "settings")
override fun onCreate() {
super.onCreate()
@ -33,22 +38,53 @@ class AppMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
val uid = authRepository.currentUserId ?: return
serviceScope.launch {
runCatching { userRepository.storeFcmToken(uid, token) }
runCatching {
userRepository.storeFcmToken(uid, token)
userRepository.storeTokenMetadata(
uid, token, TokenRegistrar.DeviceMetadata(
platform = "android",
osVersion = android.os.Build.VERSION.RELEASE ?: "unknown",
appVersion = app.closer.BuildConfig.VERSION_NAME,
sdkVersion = android.os.Build.VERSION.SDK_INT,
timestamp = System.currentTimeMillis()
)
)
}
}
}
override fun onMessageReceived(message: RemoteMessage) {
val title = message.notification?.title ?: message.data["title"] ?: return
val body = message.notification?.body ?: message.data["body"] ?: return
serviceScope.launch {
runCatching {
val prefs = dataStore.data.first()
val quietHoursEnabled = prefs[androidx.datastore.preferences.core.booleanPreferencesKey("quiet_hours")] ?: false
val startHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_hour")] ?: 22
val startMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_minute")] ?: 0
val endHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_hour")] ?: 8
val endMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_minute")] ?: 0
if (quietHoursEnabled && isInQuietHours(startHour, startMinute, endHour, endMinute)) {
return@runCatching
}
val type = message.data["type"] ?: "general"
val title = message.notification?.title
?: resolveTitle(type)
?: message.data["title"]
?: return@runCatching
val body = message.notification?.body
?: resolveBody(type)
?: message.data["body"]
?: return@runCatching
val channelId = when (type) {
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
"daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS
else -> NotificationHelper.CHANNEL_REMINDERS
}
NotificationHelper.show(
context = this,
context = this@AppMessagingService,
id = System.currentTimeMillis().toInt(),
channelId = channelId,
title = title,
@ -56,3 +92,33 @@ class AppMessagingService : FirebaseMessagingService() {
)
}
}
}
private fun resolveTitle(type: String): String? = when (type) {
"daily_question" -> "Your daily question is waiting!"
"partner_answered" -> "Your partner just answered!"
"streak" -> "Keep your streak going — answer today's question!"
else -> null
}
private fun resolveBody(type: String): String? = when (type) {
"daily_question" -> "Tap to answer today's question together."
"partner_answered" -> "See what your partner shared."
"streak" -> "Don't break the chain. Open the app now."
else -> null
}
private fun isInQuietHours(startHour: Int, startMinute: Int, endHour: Int, endMinute: Int): Boolean {
val now = Calendar.getInstance()
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
val start = startHour * 60 + startMinute
val end = endHour * 60 + endMinute
return if (start <= end) {
currentMinutes in start..end
} else {
// Quiet window crosses midnight (e.g. 22:00 → 08:00)
currentMinutes >= start || currentMinutes <= end
}
}
}

View File

@ -0,0 +1,69 @@
package app.closer.core.notifications
import android.Manifest
import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handles Android 13+ (API 33) POST_NOTIFICATIONS permission requests.
*
* Callers should:
* 1. Check [isGranted] before asking.
* 2. If [shouldShowRationale] is true, show an in-app rationale first.
* 3. Invoke [requestPermission] from an Activity context.
*/
@Singleton
class NotificationPermissionHelper @Inject constructor() {
val permission: String = Manifest.permission.POST_NOTIFICATIONS
fun isGranted(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
} else {
// Pre-Android 13 notifications are granted at install time.
true
}
}
fun shouldShowRationale(activity: Activity): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)
} else {
false
}
}
fun requestPermission(activity: Activity, requestCode: Int = REQUEST_CODE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
activity,
arrayOf(permission),
requestCode
)
}
}
fun onRequestResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
): Boolean {
if (requestCode != REQUEST_CODE) return false
val index = permissions.indexOf(permission)
if (index < 0 || index >= grantResults.size) return false
return grantResults[index] == PackageManager.PERMISSION_GRANTED
}
companion object {
const val REQUEST_CODE = 9001
}
}

View File

@ -0,0 +1,9 @@
package app.closer.core.notifications
data class QuietHours(
val enabled: Boolean = false,
val startHour: Int = 22, // 22:00 (10 PM)
val startMinute: Int = 0,
val endHour: Int = 8, // 08:00 (8 AM)
val endMinute: Int = 0
)

View File

@ -0,0 +1,51 @@
package app.closer.core.notifications
import android.os.Build
import app.closer.BuildConfig
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
/**
* Fetches the FCM token and writes it to Firestore with device metadata.
*
* Called on app start and after sign-in to ensure the server always has
* a current token for this device.
*/
@Singleton
class TokenRegistrar @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val messaging: FirebaseMessaging
) {
suspend fun register(): Result<Unit> = runCatching {
val uid = authRepository.currentUserId
?: throw IllegalStateException("No authenticated user")
val token = messaging.token.await()
val metadata = DeviceMetadata(
platform = "android",
osVersion = Build.VERSION.RELEASE ?: "unknown",
appVersion = BuildConfig.VERSION_NAME,
sdkVersion = Build.VERSION.SDK_INT,
timestamp = System.currentTimeMillis()
)
userRepository.storeFcmToken(uid, token)
userRepository.storeTokenMetadata(uid, token, metadata)
Result.success(Unit)
}.getOrElse { Result.failure(it) }
data class DeviceMetadata(
val platform: String,
val osVersion: String,
val appVersion: String,
val sdkVersion: Int,
val timestamp: Long
)
}

View File

@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import app.closer.core.notifications.QuietHours
import app.closer.domain.repository.AppSettings
import app.closer.domain.repository.SettingsRepository
import kotlinx.coroutines.flow.Flow
@ -20,6 +22,10 @@ class SettingsDataStore @Inject constructor(
private val PARTNER_ANSWERED = booleanPreferencesKey("partner_answered")
private val STREAK_REMINDER = booleanPreferencesKey("streak_reminder")
private val QUIET_HOURS = booleanPreferencesKey("quiet_hours")
private val QUIET_HOURS_START_HOUR = intPreferencesKey("quiet_hours_start_hour")
private val QUIET_HOURS_START_MINUTE = intPreferencesKey("quiet_hours_start_minute")
private val QUIET_HOURS_END_HOUR = intPreferencesKey("quiet_hours_end_hour")
private val QUIET_HOURS_END_MINUTE = intPreferencesKey("quiet_hours_end_minute")
private val ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete")
override val settings: Flow<AppSettings> = dataStore.data.map { prefs ->
@ -28,6 +34,13 @@ class SettingsDataStore @Inject constructor(
partnerAnsweredEnabled = prefs[PARTNER_ANSWERED] ?: true,
streakReminderEnabled = prefs[STREAK_REMINDER] ?: false,
quietHoursEnabled = prefs[QUIET_HOURS] ?: false,
quietHours = QuietHours(
enabled = prefs[QUIET_HOURS] ?: false,
startHour = prefs[QUIET_HOURS_START_HOUR] ?: 22,
startMinute = prefs[QUIET_HOURS_START_MINUTE] ?: 0,
endHour = prefs[QUIET_HOURS_END_HOUR] ?: 8,
endMinute = prefs[QUIET_HOURS_END_MINUTE] ?: 0
),
onboardingComplete = prefs[ONBOARDING_COMPLETE] ?: false
)
}
@ -44,6 +57,15 @@ class SettingsDataStore @Inject constructor(
override suspend fun setQuietHours(enabled: Boolean) =
dataStore.edit { it[QUIET_HOURS] = enabled }.let {}
override suspend fun setQuietHours(quietHours: QuietHours) =
dataStore.edit {
it[QUIET_HOURS] = quietHours.enabled
it[QUIET_HOURS_START_HOUR] = quietHours.startHour
it[QUIET_HOURS_START_MINUTE] = quietHours.startMinute
it[QUIET_HOURS_END_HOUR] = quietHours.endHour
it[QUIET_HOURS_END_MINUTE] = quietHours.endMinute
}.let {}
override suspend fun setOnboardingComplete(complete: Boolean) =
dataStore.edit { it[ONBOARDING_COMPLETE] = complete }.let {}
}

View File

@ -1,5 +1,6 @@
package app.closer.data.remote
import app.closer.core.notifications.TokenRegistrar.DeviceMetadata
import app.closer.domain.model.User
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
@ -83,6 +84,26 @@ class FirestoreUserDataSource @Inject constructor() {
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun storeTokenMetadata(
uid: String,
token: String,
metadata: DeviceMetadata
): Unit = suspendCancellableCoroutine { cont ->
userRef(uid).collection("fcmTokens").document(token)
.set(
mapOf(
"token" to token,
"platform" to metadata.platform,
"osVersion" to metadata.osVersion,
"appVersion" to metadata.appVersion,
"sdkVersion" to metadata.sdkVersion,
"updatedAt" to metadata.timestamp
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun clearCoupleId(uid: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).update(mapOf("coupleId" to null))

View File

@ -1,5 +1,6 @@
package app.closer.data.repository
import app.closer.core.notifications.TokenRegistrar
import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.User
import app.closer.domain.repository.UserRepository
@ -23,6 +24,12 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun storeFcmToken(uid: String, token: String) =
dataSource.storeFcmToken(uid, token)
override suspend fun storeTokenMetadata(
uid: String,
token: String,
metadata: TokenRegistrar.DeviceMetadata
) = dataSource.storeTokenMetadata(uid, token, metadata)
override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid)
override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid)

View File

@ -0,0 +1,19 @@
package app.closer.di
import app.closer.core.notifications.NotificationPermissionHelper
import app.closer.core.notifications.TokenRegistrar
import com.google.firebase.messaging.FirebaseMessaging
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NotificationModule {
@Provides
@Singleton
fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance()
}

View File

@ -1,5 +1,6 @@
package app.closer.domain.repository
import app.closer.core.notifications.QuietHours
import kotlinx.coroutines.flow.Flow
data class AppSettings(
@ -7,6 +8,7 @@ data class AppSettings(
val partnerAnsweredEnabled: Boolean = true,
val streakReminderEnabled: Boolean = false,
val quietHoursEnabled: Boolean = false,
val quietHours: QuietHours = QuietHours(),
val onboardingComplete: Boolean = false
)
@ -16,5 +18,6 @@ interface SettingsRepository {
suspend fun setPartnerAnswered(enabled: Boolean)
suspend fun setStreakReminder(enabled: Boolean)
suspend fun setQuietHours(enabled: Boolean)
suspend fun setQuietHours(quietHours: QuietHours)
suspend fun setOnboardingComplete(complete: Boolean)
}

View File

@ -1,5 +1,6 @@
package app.closer.domain.repository
import app.closer.core.notifications.TokenRegistrar
import app.closer.domain.model.User
interface UserRepository {
@ -8,6 +9,7 @@ interface UserRepository {
suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun hasProfile(uid: String): Boolean
suspend fun storeFcmToken(uid: String, token: String)
suspend fun storeTokenMetadata(uid: String, token: String, metadata: TokenRegistrar.DeviceMetadata)
suspend fun clearCoupleId(uid: String)
suspend fun deleteUserData(uid: String)
}

View File

@ -33,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.health = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
exports.health = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
const functions = __importStar(require("firebase-functions"));
var revenueCatWebhook_1 = require("./billing/revenueCatWebhook");
Object.defineProperty(exports, "revenueCatWebhook", { enumerable: true, get: function () { return revenueCatWebhook_1.revenueCatWebhook; } });
var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
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; } });
/**
* 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;AAExB;;;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;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;AAGjC;;;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,102 @@
"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.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* Callable function that queues a daily-question reminder.
*
* Expected body: { userId: string }
* Auth context supplies the caller uid; the request `userId` must match
* the caller to prevent cross-user notification spam.
*
* This is a placeholder scheduler. The actual daily scheduling will be
* handled by a Firestore trigger / Cloud Scheduler integration later.
*/
exports.sendDailyQuestionReminder = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
const userId = data.userId;
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.');
}
// Users may only request reminders for themselves.
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.');
}
await writeNotificationRecord(userId, 'daily_question', {
title: (_b = data.title) !== null && _b !== void 0 ? _b : 'Your daily question is waiting!',
body: (_c = data.body) !== null && _c !== void 0 ? _c : "Tap to answer today's question together.",
});
return { queued: true, type: 'daily_question' };
});
/**
* Callable function that queues a partner-answered notification.
*
* Expected body: { userId: string }
* Auth context must match the request `userId`.
*/
exports.sendPartnerAnsweredNotification = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
const userId = data.userId;
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.');
}
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.');
}
await writeNotificationRecord(userId, 'partner_answered', {
title: (_b = data.title) !== null && _b !== void 0 ? _b : 'Your partner just answered!',
body: (_c = data.body) !== null && _c !== void 0 ? _c : 'See what your partner shared.',
});
return { queued: true, type: 'partner_answered' };
});
async function writeNotificationRecord(userId, type, message) {
const db = admin.firestore();
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add(Object.assign(Object.assign({ type }, message), { read: false, sent: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
}
//# sourceMappingURL=reminders.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"reminders.js","sourceRoot":"","sources":["../../src/notifications/reminders.ts"],"names":[],"mappings":"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"}

View File

@ -2,6 +2,10 @@ import * as functions from 'firebase-functions'
export { revenueCatWebhook } from './billing/revenueCatWebhook'
export { syncEntitlement } from './billing/syncEntitlement'
export {
sendDailyQuestionReminder,
sendPartnerAnsweredNotification,
} from './notifications/reminders'
/**
* Basic health check callable.

View File

@ -0,0 +1,94 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
type ReminderType = 'daily_question' | 'partner_answered' | 'streak'
interface NotificationPayload {
userId: string
type: ReminderType
title?: string
body?: string
}
/**
* Callable function that queues a daily-question reminder.
*
* Expected body: { userId: string }
* Auth context supplies the caller uid; the request `userId` must match
* the caller to prevent cross-user notification spam.
*
* This is a placeholder scheduler. The actual daily scheduling will be
* handled by a Firestore trigger / Cloud Scheduler integration later.
*/
export const sendDailyQuestionReminder = functions.https.onCall(async (data: NotificationPayload, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.')
}
const userId = data.userId
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.')
}
// Users may only request reminders for themselves.
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.')
}
await writeNotificationRecord(userId, 'daily_question', {
title: data.title ?? 'Your daily question is waiting!',
body: data.body ?? "Tap to answer today's question together.",
})
return { queued: true, type: 'daily_question' }
})
/**
* Callable function that queues a partner-answered notification.
*
* Expected body: { userId: string }
* Auth context must match the request `userId`.
*/
export const sendPartnerAnsweredNotification = functions.https.onCall(async (data: NotificationPayload, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.')
}
const userId = data.userId
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.')
}
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.')
}
await writeNotificationRecord(userId, 'partner_answered', {
title: data.title ?? 'Your partner just answered!',
body: data.body ?? 'See what your partner shared.',
})
return { queued: true, type: 'partner_answered' }
})
async function writeNotificationRecord(
userId: string,
type: ReminderType,
message: { title: string; body: string }
): Promise<void> {
const db = admin.firestore()
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add({
type,
...message,
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
}