diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index f643ee69..4b4f4928 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -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 + } } } diff --git a/app/src/main/java/app/closer/core/notifications/NotificationPermissionHelper.kt b/app/src/main/java/app/closer/core/notifications/NotificationPermissionHelper.kt new file mode 100644 index 00000000..f56d7462 --- /dev/null +++ b/app/src/main/java/app/closer/core/notifications/NotificationPermissionHelper.kt @@ -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, + 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 + } +} diff --git a/app/src/main/java/app/closer/core/notifications/QuietHours.kt b/app/src/main/java/app/closer/core/notifications/QuietHours.kt new file mode 100644 index 00000000..bcade510 --- /dev/null +++ b/app/src/main/java/app/closer/core/notifications/QuietHours.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/core/notifications/TokenRegistrar.kt b/app/src/main/java/app/closer/core/notifications/TokenRegistrar.kt new file mode 100644 index 00000000..f53fbdf9 --- /dev/null +++ b/app/src/main/java/app/closer/core/notifications/TokenRegistrar.kt @@ -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 = 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 + ) +} diff --git a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt index 3156c44d..36692ea4 100644 --- a/app/src/main/java/app/closer/data/local/SettingsDataStore.kt +++ b/app/src/main/java/app/closer/data/local/SettingsDataStore.kt @@ -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 = 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 {} } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index b64bdb54..6505722a 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -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)) diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index 9554376a..a7954301 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -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) diff --git a/app/src/main/java/app/closer/di/NotificationModule.kt b/app/src/main/java/app/closer/di/NotificationModule.kt new file mode 100644 index 00000000..302d0df5 --- /dev/null +++ b/app/src/main/java/app/closer/di/NotificationModule.kt @@ -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() +} diff --git a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt index 7efba785..e462bcd8 100644 --- a/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/SettingsRepository.kt @@ -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) } diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index e7f25bdb..db8d5e83 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -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) } diff --git a/functions/dist/index.js b/functions/dist/index.js index 7a6bfe15..ca7934cc 100644 --- a/functions/dist/index.js +++ b/functions/dist/index.js @@ -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. diff --git a/functions/dist/index.js.map b/functions/dist/index.js.map index 506774d7..99a62658 100644 --- a/functions/dist/index.js.map +++ b/functions/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/functions/dist/notifications/reminders.js b/functions/dist/notifications/reminders.js new file mode 100644 index 00000000..62c9b5e0 --- /dev/null +++ b/functions/dist/notifications/reminders.js @@ -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 \ No newline at end of file diff --git a/functions/dist/notifications/reminders.js.map b/functions/dist/notifications/reminders.js.map new file mode 100644 index 00000000..58879517 --- /dev/null +++ b/functions/dist/notifications/reminders.js.map @@ -0,0 +1 @@ +{"version":3,"file":"reminders.js","sourceRoot":"","sources":["../../src/notifications/reminders.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index 8004766f..c4c641e4 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -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. diff --git a/functions/src/notifications/reminders.ts b/functions/src/notifications/reminders.ts new file mode 100644 index 00000000..d7e43d05 --- /dev/null +++ b/functions/src/notifications/reminders.ts @@ -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 { + 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(), + }) +}