From 599317e4aff63af99419316f101d77be70648c87 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 07:11:48 +0100 Subject: [PATCH 1/4] feat(rbac): add permission for reading owned push notification devices - Add new permission 'push_notification_device.read_owned' in permissions.dart - Include the new permission in the _appGuestUserPermissions set in role_permissions.dart --- lib/src/rbac/permissions.dart | 3 +++ lib/src/rbac/role_permissions.dart | 1 + 2 files changed, 4 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 5ca56f6..5215955 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -89,6 +89,9 @@ abstract class Permissions { 'push_notification_device.create_owned'; static const String pushNotificationDeviceDeleteOwned = 'push_notification_device.delete_owned'; + // Allows reading the user's own push notification devices. + 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, From 8e0b50417cf2e428b42eea55008342c16aa0f225 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 07:11:59 +0100 Subject: [PATCH 2/4] feat(push_notification): implement user-owned device query and permission - Add push_notification_device readAll operation to DataOperationRegistry - Update ModelActionPermission for collection and item GET for push_notification_device - Allow authenticated users to fetch their own notification devices - Implement ownership check for specific device retrieval --- lib/src/registry/data_operation_registry.dart | 7 +++++++ lib/src/registry/model_registry.dart | 13 ++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 680498a..ecc6a0a 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 --- 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 From 4ca732ea8d4ae501ecffe6d38fe7a8d8defd786b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 07:12:09 +0100 Subject: [PATCH 3/4] feat(database): add index for user device fetch optimization - Add 'userId_index' to push_notification_devices collection - Optimize fetching all devices for a specific user - Enhance device cleanup flow on the client --- lib/src/services/database_seeding_service.dart | 6 ++++++ 1 file changed, 6 insertions(+) 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".'); From 2bed469d5bbe63cd16688e9c3c875704958b8b99 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 22 Nov 2025 07:23:30 +0100 Subject: [PATCH 4/4] style: format --- lib/src/rbac/permissions.dart | 1 - lib/src/registry/data_operation_registry.dart | 16 ++--- .../services/push_notification_service.dart | 63 ++++++++++--------- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 5215955..307f68c 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -89,7 +89,6 @@ abstract class Permissions { 'push_notification_device.create_owned'; static const String pushNotificationDeviceDeleteOwned = 'push_notification_device.delete_owned'; - // Allows reading the user's own push notification devices. static const String pushNotificationDeviceReadOwned = 'push_notification_device.read_owned'; diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index ecc6a0a..78047e7 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -186,11 +186,11 @@ class DataOperationRegistry { ), 'push_notification_device': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -437,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/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) {