feat(notifications): FCM token registration, quiet hours, notification permission helper, reminder Cloud Functions (batch 11)
This commit is contained in:
parent
6b964935d4
commit
f3bad90ec6
|
|
@ -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,26 +38,87 @@ 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
|
||||
val type = message.data["type"] ?: "general"
|
||||
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
|
||||
|
||||
val channelId = when (type) {
|
||||
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
|
||||
else -> NotificationHelper.CHANNEL_REMINDERS
|
||||
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@AppMessagingService,
|
||||
id = System.currentTimeMillis().toInt(),
|
||||
channelId = channelId,
|
||||
title = title,
|
||||
body = body
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationHelper.show(
|
||||
context = this,
|
||||
id = System.currentTimeMillis().toInt(),
|
||||
channelId = channelId,
|
||||
title = title,
|
||||
body = body
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":3,"file":"reminders.js","sourceRoot":"","sources":["../../src/notifications/reminders.ts"],"names":[],"mappings":"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"}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue