diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 5ca56f6..307f68c 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -89,6 +89,8 @@ abstract class Permissions { 'push_notification_device.create_owned'; static const String pushNotificationDeviceDeleteOwned = 'push_notification_device.delete_owned'; + static const String pushNotificationDeviceReadOwned = + 'push_notification_device.read_owned'; // In-App Notification Permissions (User-owned) /// Allows reading the user's own in-app notifications. diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 5984237..eb3d581 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -24,6 +24,7 @@ final Set _appGuestUserPermissions = { // notifications. Permissions.pushNotificationDeviceCreateOwned, Permissions.pushNotificationDeviceDeleteOwned, + Permissions.pushNotificationDeviceReadOwned, // Allow all app users to manage their own in-app notifications. Permissions.inAppNotificationReadOwned, Permissions.inAppNotificationUpdateOwned, diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 680498a..78047e7 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -184,6 +184,13 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'push_notification_device': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -430,9 +437,9 @@ class DataOperationRegistry { .update(id: id, item: item as RemoteConfig, userId: uid), 'in_app_notification': (c, id, item, uid) => c.read>().update( - id: id, - item: item as InAppNotification, - ), + id: id, + item: item as InAppNotification, + ), }); // --- Register Item Deleters --- diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index dfe4818..58a258b 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -429,12 +429,19 @@ final modelRegistry = >{ fromJson: PushNotificationDevice.fromJson, getId: (d) => d.id, getOwnerId: (dynamic item) => (item as PushNotificationDevice).userId, + // Collection GET is allowed for a user to fetch their own notification devices. + // The generic route handler will automatically scope the query to the + // authenticated user's ID because `getOwnerId` is defined. getCollectionPermission: const ModelActionPermission( - type: RequiredPermissionType.unsupported, + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceReadOwned, ), - // Required by the ownership check middelware + // Item GET is allowed for a user to fetch a single one of their devices. + // The ownership check middleware will verify they own this specific item. getItemPermission: const ModelActionPermission( - type: RequiredPermissionType.adminOnly, + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceReadOwned, + requiresOwnershipCheck: true, ), // POST is allowed for any authenticated user to register their own device. // A custom check within the DataOperationRegistry's creator function will diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 295a7f5..7180980 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -248,6 +248,12 @@ class DatabaseSeedingService { 'unique': true, 'sparse': true, }, + { + // Optimizes fetching all devices for a specific user, which is + // needed for the device cleanup flow on the client. + 'key': {'userId': 1}, + 'name': 'userId_index', + }, ], }); _log.info('Ensured indexes for "push_notification_devices".'); diff --git a/lib/src/services/push_notification_service.dart b/lib/src/services/push_notification_service.dart index 75795a6..85bf059 100644 --- a/lib/src/services/push_notification_service.dart +++ b/lib/src/services/push_notification_service.dart @@ -32,26 +32,26 @@ class DefaultPushNotificationService implements IPushNotificationService { /// {@macro default_push_notification_service} DefaultPushNotificationService({ required DataRepository - pushNotificationDeviceRepository, + pushNotificationDeviceRepository, required DataRepository - userContentPreferencesRepository, + userContentPreferencesRepository, required DataRepository remoteConfigRepository, required DataRepository inAppNotificationRepository, required IPushNotificationClient? firebaseClient, required IPushNotificationClient? oneSignalClient, required Logger log, - }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _userContentPreferencesRepository = userContentPreferencesRepository, - _remoteConfigRepository = remoteConfigRepository, - _inAppNotificationRepository = inAppNotificationRepository, - _firebaseClient = firebaseClient, - _oneSignalClient = oneSignalClient, - _log = log; + }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, + _userContentPreferencesRepository = userContentPreferencesRepository, + _remoteConfigRepository = remoteConfigRepository, + _inAppNotificationRepository = inAppNotificationRepository, + _firebaseClient = firebaseClient, + _oneSignalClient = oneSignalClient, + _log = log; final DataRepository - _pushNotificationDeviceRepository; + _pushNotificationDeviceRepository; final DataRepository - _userContentPreferencesRepository; + _userContentPreferencesRepository; final DataRepository _remoteConfigRepository; final DataRepository _inAppNotificationRepository; final IPushNotificationClient? _firebaseClient; @@ -113,8 +113,8 @@ class DefaultPushNotificationService implements IPushNotificationService { // Check if breaking news notifications are enabled. final isBreakingNewsEnabled = pushConfig.deliveryConfigs[PushNotificationSubscriptionDeliveryType - .breakingOnly] ?? - false; + .breakingOnly] ?? + false; if (!isBreakingNewsEnabled) { _log.info('Breaking news notifications are disabled. Aborting.'); @@ -123,16 +123,16 @@ class DefaultPushNotificationService implements IPushNotificationService { // 2. Find all user preferences that contain a saved headline filter // subscribed to breaking news. This query targets the embedded 'savedHeadlineFilters' array. - final subscribedUserPreferences = - await _userContentPreferencesRepository.readAll( - filter: { - 'savedHeadlineFilters.deliveryTypes': { - r'$in': [ - PushNotificationSubscriptionDeliveryType.breakingOnly.name, - ], - }, - }, - ); + final subscribedUserPreferences = await _userContentPreferencesRepository + .readAll( + filter: { + 'savedHeadlineFilters.deliveryTypes': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ], + }, + }, + ); if (subscribedUserPreferences.items.isEmpty) { _log.info('No users subscribed to breaking news. Aborting.'); @@ -142,8 +142,9 @@ class DefaultPushNotificationService implements IPushNotificationService { // 3. Collect all unique user IDs from the preference documents. // Using a Set automatically handles deduplication. // The ID of the UserContentPreferences document is the user's ID. - final userIds = - subscribedUserPreferences.items.map((preference) => preference.id).toSet(); + final userIds = subscribedUserPreferences.items + .map((preference) => preference.id) + .toSet(); _log.info( 'Found ${subscribedUserPreferences.items.length} users with ' @@ -151,12 +152,12 @@ class DefaultPushNotificationService implements IPushNotificationService { ); // 4. Fetch all devices for all subscribed users in a single bulk query. - final allDevicesResponse = - await _pushNotificationDeviceRepository.readAll( - filter: { - 'userId': {r'$in': userIds.toList()}, - }, - ); + final allDevicesResponse = await _pushNotificationDeviceRepository + .readAll( + filter: { + 'userId': {r'$in': userIds.toList()}, + }, + ); final allDevices = allDevicesResponse.items; if (allDevices.isEmpty) {