From a34b1e698d93bdeda756b3dcce5ea93b70b998ad Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 06:26:41 +0100 Subject: [PATCH 01/12] build(deps): update core package reference - Update core package reference from 3779a8b1dbd8450d524574cf5376b7cc2ed514e7 to 4ffe8833b8a042b2b3b68550f682c5aa902a5ccc - Update both pubspec.lock and pubspec.yaml files --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 21a1723..9bf3a80 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,8 +181,8 @@ packages: dependency: "direct main" description: path: "." - ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" - resolved-ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" + ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" + resolved-ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 474c40e..c2e3790 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: 3779a8b1dbd8450d524574cf5376b7cc2ed514e7 + ref: 4ffe8833b8a042b2b3b68550f682c5aa902a5ccc http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git From ecd8f5765d3f62fec11c0d6a5430581198dc7a42 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:26:31 +0100 Subject: [PATCH 02/12] feat(rbac): define permissions for ugc Introduces new permission constants for managing user-generated content. This includes permissions for creating, reading, updating, and deleting engagements and reports, scoped to the owner. These permissions are essential for securing the new UGC endpoints and ensuring users can only manage their own content. --- lib/src/rbac/permissions.dart | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 58761c6..6e43f56 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -103,4 +103,24 @@ abstract class Permissions { /// Allows deleting the user's own in-app notifications. static const String inAppNotificationDeleteOwned = 'in_app_notification.delete_owned'; + + // Engagement Permissions (User-owned) + /// Allows creating an engagement (reaction or comment). + static const String engagementCreateOwned = 'engagement.create_owned'; + + /// Allows reading the user's own engagements. + static const String engagementReadOwned = 'engagement.read_owned'; + + /// Allows updating the user's own engagement (e.g., changing a reaction). + static const String engagementUpdateOwned = 'engagement.update_owned'; + + /// Allows deleting the user's own engagement. + static const String engagementDeleteOwned = 'engagement.delete_owned'; + + // Report Permissions (User-owned) + /// Allows creating a report. + static const String reportCreateOwned = 'report.create_owned'; + + /// Allows reading the user's own reports. + static const String reportReadOwned = 'report.read_owned'; } From b49a92fca05abac6ecb898be7e55ca43054cbd33 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:26:43 +0100 Subject: [PATCH 03/12] feat(rbac): grant ugc permissions to app roles Assigns the new engagement and report permissions to the `_appGuestUserPermissions` set. Since guest, standard, and premium users all inherit from this base set, this change grants all application users the ability to create and manage their own engagements and reports. This is a necessary step to allow users to interact with the new community features. --- lib/src/rbac/role_permissions.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 96b77a7..ecdc70c 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,10 +25,19 @@ final Set _appGuestUserPermissions = { Permissions.pushNotificationDeviceCreateOwned, Permissions.pushNotificationDeviceDeleteOwned, Permissions.pushNotificationDeviceReadOwned, + // Allow all app users to manage their own in-app notifications. Permissions.inAppNotificationReadOwned, Permissions.inAppNotificationUpdateOwned, Permissions.inAppNotificationDeleteOwned, + + // UGC Permissions + Permissions.engagementCreateOwned, + Permissions.engagementReadOwned, + Permissions.engagementUpdateOwned, + Permissions.engagementDeleteOwned, + Permissions.reportCreateOwned, + Permissions.reportReadOwned, }; final Set _appStandardUserPermissions = { From 22d00db32e32aa32ea6b3ffd8045231eba2e6275 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:27:33 +0100 Subject: [PATCH 04/12] feat(deps): provide engagement and report repositories Instantiates `DataMongodb` clients and `DataRepository` instances for the `Engagement` and `Report` models. These new repositories are then provided to the application's dependency injection container. This makes the services for managing engagements and reports available throughout the application, particularly for the generic data API routes. --- lib/src/config/app_dependencies.dart | 33 +++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index da51e5e..1b6d1e4 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -16,7 +16,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/countr import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_push_notification_client.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; @@ -28,7 +28,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/push_n import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/verification_code_storage_service.dart'; import 'package:http_client/http_client.dart'; import 'package:logging/logging.dart'; @@ -73,6 +73,8 @@ class AppDependencies { late final DataRepository remoteConfigRepository; late final DataRepository inAppNotificationRepository; + late final DataRepository engagementRepository; + late final DataRepository reportRepository; late final EmailRepository emailRepository; // Services @@ -83,7 +85,7 @@ class AppDependencies { late final AuthService authService; late final DashboardSummaryService dashboardSummaryService; late final PermissionService permissionService; - late final UserPreferenceLimitService userPreferenceLimitService; + late final UserActionLimitService userActionLimitService; late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; late final IPushNotificationService pushNotificationService; @@ -231,6 +233,22 @@ class AppDependencies { _log.info('Initialized data client for InAppNotification.'); + final engagementClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'engagements', + fromJson: Engagement.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + + final reportClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'reports', + fromJson: Report.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + // --- Conditionally Initialize Push Notification Clients --- // Firebase @@ -319,6 +337,9 @@ class AppDependencies { inAppNotificationRepository = DataRepository( dataClient: inAppNotificationClient, ); + engagementRepository = DataRepository(dataClient: engagementClient); + reportRepository = DataRepository(dataClient: reportClient); + // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add // the 'Authorization: Bearer ' header. @@ -368,9 +389,11 @@ class AppDependencies { topicRepository: topicRepository, sourceRepository: sourceRepository, ); - userPreferenceLimitService = DefaultUserPreferenceLimitService( + userActionLimitService = DefaultUserActionLimitService( remoteConfigRepository: remoteConfigRepository, - log: Logger('DefaultUserPreferenceLimitService'), + engagementRepository: engagementRepository, + reportRepository: reportRepository, + log: Logger('DefaultUserActionLimitService'), ); rateLimitService = MongoDbRateLimitService( connectionManager: _mongoDbConnectionManager, From 86ff11b2069a4a429dcc996f0bd00aa82befd6e2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:50:06 +0100 Subject: [PATCH 05/12] feat(registry): register engagement and report models Adds `ModelConfig` entries for 'engagement' and 'report' to the `modelRegistry`. This configuration defines the authorization rules for each model, such as requiring ownership for all operations and using specific `_owned` permissions. Registering these models makes them available through the generic `/api/v1/data` endpoint, enabling clients to perform CRUD operations on them. --- lib/src/config/model_registry.dart | 563 +++++++++++++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 lib/src/config/model_registry.dart diff --git a/lib/src/config/model_registry.dart b/lib/src/config/model_registry.dart new file mode 100644 index 0000000..32d8278 --- /dev/null +++ b/lib/src/config/model_registry.dart @@ -0,0 +1,563 @@ +// ignore_for_file: comment_references + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_client/data_client.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Defines the type of permission check required for a specific action. +enum RequiredPermissionType { + /// No specific permission check is required (e.g., public access). + /// Note: This assumes the parent route group middleware allows unauthenticated + /// access if needed. The /data route requires authentication by default. + none, + + /// Requires the user to have the [UserRole.admin] role. + adminOnly, + + /// Requires the user to have a specific permission string. + specificPermission, + + /// This action is not supported via this generic route. + /// It is typically handled by a dedicated service or route. + unsupported, +} + +/// Configuration for the authorization requirements of a single HTTP method +/// on a data model. +class ModelActionPermission { + /// {@macro model_action_permission} + const ModelActionPermission({ + required this.type, + this.permission, + this.requiresOwnershipCheck = false, + this.requiresAuthentication = true, + }) : assert( + type != RequiredPermissionType.specificPermission || + permission != null, + 'Permission string must be provided for specificPermission type', + ); + + /// The type of permission check required. + final RequiredPermissionType type; + + /// The specific permission string required if [type] is + /// [RequiredPermissionType.specificPermission]. + final String? permission; + + /// Whether an additional check is required to verify the authenticated user + /// is the owner of the specific data item being accessed (for item-specific + /// methods like GET, PUT, DELETE on `/[id]`). + final bool requiresOwnershipCheck; + + /// Whether this action requires an authenticated user. + /// + /// If `true` (default), the `authenticationProvider` middleware will ensure + /// a valid [User] is present in the context. If `false`, the action can + /// be performed by unauthenticated clients. + final bool requiresAuthentication; +} + +/// {@template model_config} +/// Configuration holder for a specific data model type [T]. +/// +/// This class encapsulates the type-specific operations (like deserialization +/// from JSON, ID extraction, and owner ID extraction) and authorization +/// requirements needed by the generic `/api/v1/data` endpoint handlers and +/// middleware. It allows those handlers to work with different data models +/// without needing explicit type checks for these common operations. +/// +/// An instance of this config is looked up via the [modelRegistry] based on the +/// `?model=` query parameter provided in the request. +/// {@endtemplate} +class ModelConfig { + /// {@macro model_config} + const ModelConfig({ + required this.fromJson, + required this.getId, + required this.getCollectionPermission, + required this.getItemPermission, + required this.postPermission, + required this.putPermission, + required this.deletePermission, + this.getOwnerId, // Optional: Function to get owner ID for user-owned models + }); + + /// Function to deserialize JSON into an object of type [T]. + final FromJson fromJson; + + /// Function to extract the unique string ID from an item of type [T]. + final String Function(T item) getId; + + /// Optional function to extract the unique string ID of the owner from an + /// item of type [T]. Required for models where `requiresOwnershipCheck` + /// is true for any action. + final String? Function(T item)? getOwnerId; + + /// Authorization configuration for GET requests to the collection endpoint. + final ModelActionPermission getCollectionPermission; + + /// Authorization configuration for GET requests to a specific item endpoint. + final ModelActionPermission getItemPermission; + + /// Authorization configuration for POST requests. + final ModelActionPermission postPermission; + + /// Authorization configuration for PUT requests. + final ModelActionPermission putPermission; + + /// Authorization configuration for DELETE requests. + final ModelActionPermission deletePermission; +} + +/// {@template model_registry} +/// Central registry mapping model name strings (used in the `?model=` query parameter) +/// to their corresponding [ModelConfig] instances. +/// +/// This registry is the core component enabling the generic `/api/v1/data` endpoint. +/// The middleware (`routes/api/v1/data/_middleware.dart`) uses this map to: +/// 1. Validate the `model` query parameter provided by the client. +/// 2. Retrieve the correct [ModelConfig] containing type-specific functions +/// (like `fromJson`, `getOwnerId`) and authorization metadata needed by the +/// generic route handlers (`index.dart`, `[id].dart`) and authorization middleware. +/// +/// While individual repositories (`DataRepository`, etc.) are provided +/// directly in the main `routes/_middleware.dart`, this registry provides the +/// *metadata* needed to work with those repositories generically based on the +/// request's `model` parameter. +/// {@endtemplate} +final modelRegistry = >{ + 'headline': ModelConfig( + fromJson: Headline.fromJson, + getId: (h) => h.id, + // Headlines: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.headlineRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.headlineRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'topic': ModelConfig( + fromJson: Topic.fromJson, + getId: (t) => t.id, + // Topics: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.topicRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.topicRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'source': ModelConfig( + fromJson: Source.fromJson, + getId: (s) => s.id, + // Sources: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.sourceRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.sourceRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'country': ModelConfig( + fromJson: Country.fromJson, + getId: (c) => c.id, + // Countries: Static data, read-only for all authenticated users. + // Modification is not allowed via the API as this is real-world data + // managed by database seeding. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.countryRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.countryRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'language': ModelConfig( + fromJson: Language.fromJson, + getId: (l) => l.id, + // Languages: Static data, read-only for all authenticated users. + // Modification is not allowed via the API as this is real-world data + // managed by database seeding. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.languageRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.languageRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'user': ModelConfig( + fromJson: User.fromJson, + getId: (u) => u.id, + getOwnerId: (dynamic item) => + (item as User).id as String?, // User is the owner of their profile + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only admin can list all users + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userReadOwned, // User can read their own + requiresOwnershipCheck: true, // Must be the owner + requiresAuthentication: true, + ), + // User creation is handled exclusively by the authentication service + // (e.g., during sign-up) and is not supported via the generic data API. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // User updates are handled by a custom updater in DataOperationRegistry. + // - Admins can update roles (`appRole`, `dashboardRole`). + // - Users can update their own `feedDecoratorStatus` and `email`. + // The `userUpdateOwned` permission, combined with the ownership check, + // provides the entry point for both admins (who bypass ownership checks) + // and users to target a user object for an update. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userUpdateOwned, // User can update their own + requiresOwnershipCheck: true, // Must be the owner + requiresAuthentication: true, + ), + // User deletion is handled exclusively by the authentication service + // (e.g., via a dedicated "delete account" endpoint) and is not + // supported via the generic data API. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + ), + 'app_settings': ModelConfig( + fromJson: AppSettings.fromJson, + getId: (s) => s.id, + getOwnerId: (dynamic item) => (item as AppSettings).id, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appSettingsReadOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Creation of AppSettings is handled by the authentication service + // during user creation, not via a direct POST to /api/v1/data. + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appSettingsUpdateOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Deletion of AppSettings is handled by the authentication service + // during account deletion, not via a direct DELETE to /api/v1/data. + ), + ), + 'user_content_preferences': ModelConfig( + fromJson: UserContentPreferences.fromJson, + getId: (p) => p.id, + getOwnerId: (dynamic item) => + (item as UserContentPreferences).id + as String?, // User ID is the owner ID + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userContentPreferencesReadOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Creation of UserContentPreferences is handled by the authentication + // service during user creation, not via a direct POST to /api/v1/data. + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userContentPreferencesUpdateOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Deletion of UserContentPreferences is handled by the authentication + // service during account deletion, not via a direct DELETE to /api/v1/data. + ), + ), + 'remote_config': ModelConfig( + fromJson: RemoteConfig.fromJson, + getId: (config) => config.id, + getOwnerId: null, // RemoteConfig is a global resource, not user-owned + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.none, + requiresAuthentication: false, // Make remote_config GET public + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can create + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can update + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can delete + requiresAuthentication: true, + ), + ), + 'dashboard_summary': ModelConfig( + fromJson: DashboardSummary.fromJson, + getId: (summary) => summary.id, + getOwnerId: null, // Not a user-owned resource + // Permissions: Read-only for admins, all other actions unsupported. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'push_notification_device': ModelConfig( + 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.specificPermission, + permission: Permissions.pushNotificationDeviceReadOwned, + ), + // 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.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 + // ensure the `userId` in the request body matches the authenticated user. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceCreateOwned, + // Ownership check is on the *new* item's payload, which is handled + // by the creator function, not the standard ownership middleware. + requiresOwnershipCheck: false, + ), + // PUT is not supported. To update a token, the client should delete the + // old device registration and create a new one. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // DELETE is allowed for any authenticated user to delete their own device + // registration (e.g., on sign-out). The ownership check middleware will + // verify the user owns the device record before allowing deletion. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'in_app_notification': ModelConfig( + fromJson: InAppNotification.fromJson, + getId: (n) => n.id, + getOwnerId: (dynamic item) => (item as InAppNotification).userId, + // Collection GET is allowed for a user to fetch their own notification inbox. + // The ownership check ensures they only see their own notifications. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // Item GET is allowed for a user to fetch a single notification. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // POST is unsupported as notifications are created by the system, not users. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // PUT is allowed for a user to update their own notification (e.g., mark as read). + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationUpdateOwned, + requiresOwnershipCheck: true, + ), + // DELETE is allowed for a user to delete their own notification. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'engagement': ModelConfig( + fromJson: Engagement.fromJson, + getId: (e) => e.id, + getOwnerId: (dynamic item) => (item as Engagement).userId, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementReadOwned, + requiresOwnershipCheck: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementReadOwned, + requiresOwnershipCheck: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementCreateOwned, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementUpdateOwned, + requiresOwnershipCheck: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'report': ModelConfig( + fromJson: Report.fromJson, + getId: (r) => r.id, + getOwnerId: (dynamic item) => (item as Report).reporterUserId, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.reportReadOwned, + requiresOwnershipCheck: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.reportCreateOwned, + ), + putPermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), + deletePermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), + ), +}; + +/// Type alias for the ModelRegistry map for easier provider usage. +typedef ModelRegistryMap = Map>; + +/// Dart Frog provider function factory for the entire [modelRegistry]. +/// +/// This makes the `modelRegistry` map available for injection into the +/// request context via `context.read()`. It's primarily +/// used by the middleware in `routes/api/v1/data/_middleware.dart`. +final Middleware modelRegistryProvider = provider( + (_) => modelRegistry, +); From c266c974723d286540d0f2d67089c65c1c7d2e28 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:53:06 +0100 Subject: [PATCH 06/12] feat(db): add indexes for ugc collections Updates the `_ensureIndexes` method in the `DatabaseSeedingService` to create indexes on the `engagements` and `reports` collections. Indexes are added for `userId` to optimize fetching user-specific content, and for `entityId` and `entityType` to efficiently query for content related to a specific item (e.g., all engagements for a headline). --- .../services/database_seeding_service.dart | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 2d1eff2..91345e4 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -281,6 +281,38 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "in_app_notifications".'); + // Indexes for the engagements collection + await _db.runCommand({ + 'createIndexes': 'engagements', + 'indexes': [ + { + // Optimizes fetching all engagements for a specific user. + 'key': {'userId': 1}, + 'name': 'userId_index', + }, + { + // Optimizes fetching all engagements for a specific entity + // (e.g., a headline). + 'key': {'entityId': 1, 'entityType': 1}, + 'name': 'entity_index', + }, + ], + }); + _log.info('Ensured indexes for "engagements".'); + + // Indexes for the reports collection + await _db.runCommand({ + 'createIndexes': 'reports', + 'indexes': [ + { + // Optimizes fetching all reports submitted by a specific user. + 'key': {'reporterUserId': 1}, + 'name': 'reporterUserId_index', + }, + ], + }); + _log.info('Ensured indexes for "reports".'); + _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { _log.severe('Failed to create database indexes.', e, s); From 9248839a33c627621fb3b4322bca3f6045a11eba Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:55:21 +0100 Subject: [PATCH 07/12] refactor(services): renale and expand user expand user preference limit service for ugc Expands the `UserActionLimitService` interface with new, correctly named methods: `checkEngagementCreationLimit` and `checkReportCreationLimit`. This change correctly frames the limit checks around the primary entities (`Engagement`, `Report`) being created, rather than their sub-components. The `checkEngagementCreationLimit` method now accepts the `Engagement` object to allow for conditional logic based on its content (e.g., presence of a comment). --- .../services/user_action_limit_service.dart | 35 +++++++++++++++++++ .../user_preference_limit_service.dart | 23 ------------ 2 files changed, 35 insertions(+), 23 deletions(-) create mode 100644 lib/src/services/user_action_limit_service.dart delete mode 100644 lib/src/services/user_preference_limit_service.dart diff --git a/lib/src/services/user_action_limit_service.dart b/lib/src/services/user_action_limit_service.dart new file mode 100644 index 0000000..2a1899e --- /dev/null +++ b/lib/src/services/user_action_limit_service.dart @@ -0,0 +1,35 @@ +import 'package:core/core.dart'; + +/// {@template user_action_limit_service} +/// A service responsible for enforcing all user-related limits based on +/// the user's role and the application's remote configuration. +/// +/// This service centralizes validation for both static preference counts +/// (e.g., number of saved filters) and transactional, time-windowed actions +/// (e.g., number of engagements or reports per day). +/// {@endtemplate} +abstract class UserActionLimitService { + /// {@macro user_action_limit_service} + const UserActionLimitService(); + + /// Validates an updated [UserContentPreferences] object against all limits + /// for preference counts (e.g., followed items, saved headlines). + /// + /// Throws a [ForbiddenException] if any limit is exceeded. + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }); + + /// Validates if a user can create a new [Engagement]. + /// + /// This method checks against `reactionsPerDay` and, if the engagement + /// contains a comment, also checks against `commentsPerDay`. + Future checkEngagementCreationLimit( + {required User user, required Engagement engagement}); + + /// Validates if a user can create a new [Report]. + /// + /// This method checks against the `reportsPerDay` limit. + Future checkReportCreationLimit({required User user}); +} diff --git a/lib/src/services/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart deleted file mode 100644 index affc522..0000000 --- a/lib/src/services/user_preference_limit_service.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:core/core.dart'; - -/// {@template user_preference_limit_service} -/// A service responsible for enforcing all user preference limits based on -/// the user's role and the application's remote configuration. -/// -/// This service centralizes validation for both the `Interest` model and -/// the `UserContentPreferences` model (e.g., followed items, saved headlines). -/// {@endtemplate} -abstract class UserPreferenceLimitService { - /// {@macro user_preference_limit_service} - const UserPreferenceLimitService(); - - /// Validates an updated [UserContentPreferences] object against all limits - /// defined in `RemoteConfig`, including interests, followed items, and - /// saved headlines. - /// - /// Throws a [ForbiddenException] if any limit is exceeded. - Future checkUserContentPreferencesLimits({ - required User user, - required UserContentPreferences updatedPreferences, - }); -} From 7fd22a26ab095e53eb8f5d2a68b6b9320f7a652b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:56:25 +0100 Subject: [PATCH 08/12] feat(services): implement engagement and report limit checks Overhauls the `DefaultUserActionLimitService` to correctly enforce daily limits for engagements and reports. The service now injects the `Engagement` and `Report` repositories to perform database counts. - `checkEngagementCreationLimit`: Checks the `reactionsPerDay` limit on every call. If the engagement contains a comment, it also checks the `commentsPerDay` limit. - `checkReportCreationLimit`: Checks the `reportsPerDay` limit. This centralizes all UGC limit logic within the service layer, adhering to the established architectural pattern. --- ...=> default_user_action_limit_service.dart} | 125 ++++++++++++++++-- 1 file changed, 116 insertions(+), 9 deletions(-) rename lib/src/services/{default_user_preference_limit_service.dart => default_user_action_limit_service.dart} (66%) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_action_limit_service.dart similarity index 66% rename from lib/src/services/default_user_preference_limit_service.dart rename to lib/src/services/default_user_action_limit_service.dart index 8b95280..6140834 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_action_limit_service.dart @@ -1,22 +1,28 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:logging/logging.dart'; -/// {@template default_user_preference_limit_service} -/// Default implementation of [UserPreferenceLimitService] that enforces limits -/// based on user role and the `InterestConfig` and `UserPreferenceConfig` +/// {@template default_user_action_limit_service} +/// Default implementation of [UserActionLimitService] that enforces limits +/// based on user role and the `UserLimitsConfig` /// sections within the application's [RemoteConfig]. /// {@endtemplate} -class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { - /// {@macro default_user_preference_limit_service} - const DefaultUserPreferenceLimitService({ +class DefaultUserActionLimitService implements UserActionLimitService { + /// {@macro default_user_action_limit_service} + const DefaultUserActionLimitService({ required DataRepository remoteConfigRepository, + required DataRepository engagementRepository, + required DataRepository reportRepository, required Logger log, }) : _remoteConfigRepository = remoteConfigRepository, + _engagementRepository = engagementRepository, + _reportRepository = reportRepository, _log = log; final DataRepository _remoteConfigRepository; + final DataRepository _engagementRepository; + final DataRepository _reportRepository; final Logger _log; // Assuming a fixed ID for the RemoteConfig document @@ -28,7 +34,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { required UserContentPreferences updatedPreferences, }) async { _log.info( - 'Checking all user content preferences limits for user ${user.id}.', + 'Checking all user action limits for user ${user.id}.', ); final remoteConfig = await _remoteConfigRepository.read( id: _remoteConfigId, @@ -47,6 +53,8 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ); // --- 1. Check general preference limits --- + // Note: The checks for commentsPerDay and reportsPerDay are not performed + // here. They are action-based and enforced by the RateLimitService. if (updatedPreferences.followedCountries.length > followedItemsLimit) { _log.warning( 'User ${user.id} exceeded followed countries limit: ' @@ -202,7 +210,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { SavedFilterLimits savedHeadlineFiltersLimit, SavedFilterLimits savedSourceFiltersLimit, ) - _getLimitsForRole( + _getPreferenceLimitsForRole( AppUserRole role, UserLimitsConfig limits, ) { @@ -237,4 +245,103 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { savedSourceFiltersLimit, ); } + + @override + Future checkEngagementCreationLimit({ + required User user, + required Engagement engagement, + }) async { + _log.info('Checking engagement creation limits for user ${user.id}.'); + final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final limits = remoteConfig.user.limits; + + // --- 1. Check Reaction Limit --- + final reactionsLimit = limits.reactionsPerDay[user.appRole]; + if (reactionsLimit == null) { + throw StateError( + 'Reactions per day limit not configured for role: ${user.appRole}', + ); + } + + // Count all engagements in the last 24 hours for the reaction limit. + final twentyFourHoursAgo = DateTime.now().subtract(const Duration(hours: 24)); + final reactionCount = await _engagementRepository.count( + filter: { + 'userId': user.id, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (reactionCount >= reactionsLimit) { + _log.warning( + 'User ${user.id} exceeded reactions per day limit: $reactionsLimit.', + ); + throw ForbiddenException( + 'You have reached your daily limit for reactions.', + ); + } + + // --- 2. Check Comment Limit (only if a comment is present) --- + if (engagement.comment != null) { + final commentsLimit = limits.commentsPerDay[user.appRole]; + if (commentsLimit == null) { + throw StateError( + 'Comments per day limit not configured for role: ${user.appRole}', + ); + } + + // Count engagements with comments in the last 24 hours. + final commentCount = await _engagementRepository.count( + filter: { + 'userId': user.id, + 'comment': {r'$exists': true, r'$ne': null}, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (commentCount >= commentsLimit) { + _log.warning( + 'User ${user.id} exceeded comments per day limit: $commentsLimit.', + ); + throw ForbiddenException( + 'You have reached your daily limit for comments.', + ); + } + } + + _log.info( + 'Engagement creation limit checks passed for user ${user.id}.', + ); + } + + @override + Future checkReportCreationLimit({required User user}) async { + _log.info('Checking report creation limits for user ${user.id}.'); + final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final limits = remoteConfig.user.limits; + + final reportsLimit = limits.reportsPerDay[user.appRole]; + if (reportsLimit == null) { + throw StateError( + 'Reports per day limit not configured for role: ${user.appRole}', + ); + } + + final twentyFourHoursAgo = DateTime.now().subtract(const Duration(hours: 24)); + final reportCount = await _reportRepository.count( + filter: { + 'reporterUserId': user.id, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (reportCount >= reportsLimit) { + _log.warning( + 'User ${user.id} exceeded reports per day limit: $reportsLimit.', + ); + throw ForbiddenException('You have reached your daily limit for reports.'); + } + + _log.info('Report creation limit checks passed for user ${user.id}.'); + } } From 488017906eea5e4b643e598ce7f37b9ecdfd3975 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:57:55 +0100 Subject: [PATCH 09/12] feat(registry): add data operations for engagement and report - Register read, readAll, create, update, and delete operations for Engagement and Report entities - Implement custom creators for engagement and report with security and limit checks - Update existing updaters and deleters for other entities - Rename UserPreferenceLimitService to UserActionLimitService for better naming consistency --- lib/src/registry/data_operation_registry.dart | 80 ++++++++++++++++++- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index b4fbc60..133059c 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -9,7 +9,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:logging/logging.dart'; // --- Typedefs for Data Operations --- @@ -128,6 +128,10 @@ class DataOperationRegistry { 'push_notification_device': (c, id) => c .read>() .read(id: id, userId: null), + 'engagement': (c, id) => + c.read>().read(id: id, userId: null), + 'report': (c, id) => + c.read>().read(id: id, userId: null), }); // --- Register "Read All" Readers --- @@ -191,6 +195,19 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'engagement': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), + 'report': (c, uid, f, s, p) => c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -279,6 +296,49 @@ class DataOperationRegistry { userId: null, ); }, + 'engagement': (context, item, uid) async { + _log.info('Executing custom creator for engagement.'); + final authenticatedUser = context.read(); + final userActionLimitService = context.read(); + final engagementToCreate = item as Engagement; + + // Security Check + if (engagementToCreate.userId != authenticatedUser.id) { + throw const ForbiddenException( + 'You can only create engagements for your own account.', + ); + } + + // Limit Check: Delegate to the centralized service. + await userActionLimitService.checkEngagementCreationLimit( + user: authenticatedUser, + engagement: engagementToCreate, + ); + + return context.read>().create( + item: engagementToCreate, + userId: null, + ); + }, + 'report': (context, item, uid) async { + _log.info('Executing custom creator for report.'); + final authenticatedUser = context.read(); + final userActionLimitService = context.read(); + final reportToCreate = item as Report; + + // Security Check + if (reportToCreate.reporterUserId != authenticatedUser.id) { + throw const ForbiddenException( + 'You can only create reports for your own account.', + ); + } + + // Limit Check + await userActionLimitService.checkReportCreationLimit( + user: authenticatedUser); + + return context.read>().create(item: item); + }, }); // --- Register Item Updaters --- @@ -398,8 +458,7 @@ class DataOperationRegistry { ); final authenticatedUser = context.read(); final permissionService = context.read(); - final userPreferenceLimitService = context - .read(); + final userActionLimitService = context.read(); final userContentPreferencesRepository = context .read>(); @@ -416,7 +475,7 @@ class DataOperationRegistry { 'User ${authenticatedUser.id} has bypass permission. Skipping limit checks.', ); } else { - await userPreferenceLimitService.checkUserContentPreferencesLimits( + await userActionLimitService.checkUserContentPreferencesLimits( user: authenticatedUser, updatedPreferences: preferencesToUpdate, ); @@ -440,6 +499,15 @@ class DataOperationRegistry { id: id, item: item as InAppNotification, ), + 'engagement': (c, id, item, uid) => + c.read>().update( + id: id, + item: item as Engagement, + ), + 'report': (c, id, item, uid) => c.read>().update( + id: id, + item: item as Report, + ), }); // --- Register Item Deleters --- @@ -467,6 +535,10 @@ class DataOperationRegistry { 'in_app_notification': (c, id, uid) => c .read>() .delete(id: id, userId: uid), + 'engagement': (c, id, uid) => + c.read>().delete(id: id, userId: uid), + 'report': (c, id, uid) => + c.read>().delete(id: id, userId: uid), }); } } From dad466d37382a4be2d1604ce81b3da1651c9e14b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 07:58:50 +0100 Subject: [PATCH 10/12] refactor(deps): rename UserPreferenceLimitService to UserActionLimitService - Update import statement for UserActionLimitService - Replace UserPreferenceLimitService with UserActionLimitService in middleware --- routes/_middleware.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 21607b8..b09a675 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -16,7 +16,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/fireba import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/verification_code_storage_service.dart'; import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; @@ -169,8 +169,8 @@ Handler middleware(Handler handler) { ) .use(provider((_) => deps.permissionService)) .use( - provider( - (_) => deps.userPreferenceLimitService, + provider( + (_) => deps.userActionLimitService, ), ) .use(provider((_) => deps.rateLimitService)) From 700fc49391a42f16fca1521388182568f27f9347 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 08:18:39 +0100 Subject: [PATCH 11/12] fix(user-action-limit): correct method name and add const keyword - Update _getLimitsForRole to _getPreferenceLimitsForRole - Add const keyword to ForbiddenException instances --- lib/src/services/default_user_action_limit_service.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/services/default_user_action_limit_service.dart b/lib/src/services/default_user_action_limit_service.dart index 6140834..44c04f3 100644 --- a/lib/src/services/default_user_action_limit_service.dart +++ b/lib/src/services/default_user_action_limit_service.dart @@ -47,7 +47,7 @@ class DefaultUserActionLimitService implements UserActionLimitService { savedHeadlinesLimit, savedHeadlineFiltersLimit, savedSourceFiltersLimit, - ) = _getLimitsForRole( + ) = _getPreferenceLimitsForRole( user.appRole, limits, ); @@ -276,7 +276,7 @@ class DefaultUserActionLimitService implements UserActionLimitService { _log.warning( 'User ${user.id} exceeded reactions per day limit: $reactionsLimit.', ); - throw ForbiddenException( + throw const ForbiddenException( 'You have reached your daily limit for reactions.', ); } @@ -303,7 +303,7 @@ class DefaultUserActionLimitService implements UserActionLimitService { _log.warning( 'User ${user.id} exceeded comments per day limit: $commentsLimit.', ); - throw ForbiddenException( + throw const ForbiddenException( 'You have reached your daily limit for comments.', ); } @@ -339,7 +339,7 @@ class DefaultUserActionLimitService implements UserActionLimitService { _log.warning( 'User ${user.id} exceeded reports per day limit: $reportsLimit.', ); - throw ForbiddenException('You have reached your daily limit for reports.'); + throw const ForbiddenException('You have reached your daily limit for reports.'); } _log.info('Report creation limit checks passed for user ${user.id}.'); From d5bfa21a6fe27c5a381b92f64e766273ae2a6a99 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 08:29:52 +0100 Subject: [PATCH 12/12] style: format --- lib/src/config/model_registry.dart | 8 +++-- lib/src/rbac/role_permissions.dart | 2 +- lib/src/registry/data_operation_registry.dart | 31 ++++++++++--------- .../default_user_action_limit_service.dart | 20 +++++++++--- .../services/user_action_limit_service.dart | 6 ++-- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/lib/src/config/model_registry.dart b/lib/src/config/model_registry.dart index 32d8278..67ea336 100644 --- a/lib/src/config/model_registry.dart +++ b/lib/src/config/model_registry.dart @@ -545,8 +545,12 @@ final modelRegistry = >{ type: RequiredPermissionType.specificPermission, permission: Permissions.reportCreateOwned, ), - putPermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), - deletePermission: const ModelActionPermission(type: RequiredPermissionType.unsupported), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), ), }; diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index ecdc70c..6c5288c 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,7 +25,7 @@ final Set _appGuestUserPermissions = { 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 133059c..28b8c2e 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -197,17 +197,17 @@ class DataOperationRegistry { ), 'engagement': (c, uid, f, s, p) => c.read>().readAll( - userId: uid, - filter: f, - sort: s, - pagination: p, - ), - 'report': (c, uid, f, s, p) => c.read>().readAll( userId: uid, filter: f, sort: s, pagination: p, ), + 'report': (c, uid, f, s, p) => c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -316,9 +316,9 @@ class DataOperationRegistry { ); return context.read>().create( - item: engagementToCreate, - userId: null, - ); + item: engagementToCreate, + userId: null, + ); }, 'report': (context, item, uid) async { _log.info('Executing custom creator for report.'); @@ -335,7 +335,8 @@ class DataOperationRegistry { // Limit Check await userActionLimitService.checkReportCreationLimit( - user: authenticatedUser); + user: authenticatedUser, + ); return context.read>().create(item: item); }, @@ -501,13 +502,13 @@ class DataOperationRegistry { ), 'engagement': (c, id, item, uid) => c.read>().update( - id: id, - item: item as Engagement, - ), - 'report': (c, id, item, uid) => c.read>().update( id: id, - item: item as Report, + item: item as Engagement, ), + 'report': (c, id, item, uid) => c.read>().update( + id: id, + item: item as Report, + ), }); // --- Register Item Deleters --- diff --git a/lib/src/services/default_user_action_limit_service.dart b/lib/src/services/default_user_action_limit_service.dart index 44c04f3..ca72890 100644 --- a/lib/src/services/default_user_action_limit_service.dart +++ b/lib/src/services/default_user_action_limit_service.dart @@ -252,7 +252,9 @@ class DefaultUserActionLimitService implements UserActionLimitService { required Engagement engagement, }) async { _log.info('Checking engagement creation limits for user ${user.id}.'); - final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); final limits = remoteConfig.user.limits; // --- 1. Check Reaction Limit --- @@ -264,7 +266,9 @@ class DefaultUserActionLimitService implements UserActionLimitService { } // Count all engagements in the last 24 hours for the reaction limit. - final twentyFourHoursAgo = DateTime.now().subtract(const Duration(hours: 24)); + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), + ); final reactionCount = await _engagementRepository.count( filter: { 'userId': user.id, @@ -317,7 +321,9 @@ class DefaultUserActionLimitService implements UserActionLimitService { @override Future checkReportCreationLimit({required User user}) async { _log.info('Checking report creation limits for user ${user.id}.'); - final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); final limits = remoteConfig.user.limits; final reportsLimit = limits.reportsPerDay[user.appRole]; @@ -327,7 +333,9 @@ class DefaultUserActionLimitService implements UserActionLimitService { ); } - final twentyFourHoursAgo = DateTime.now().subtract(const Duration(hours: 24)); + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), + ); final reportCount = await _reportRepository.count( filter: { 'reporterUserId': user.id, @@ -339,7 +347,9 @@ class DefaultUserActionLimitService implements UserActionLimitService { _log.warning( 'User ${user.id} exceeded reports per day limit: $reportsLimit.', ); - throw const ForbiddenException('You have reached your daily limit for reports.'); + throw const ForbiddenException( + 'You have reached your daily limit for reports.', + ); } _log.info('Report creation limit checks passed for user ${user.id}.'); diff --git a/lib/src/services/user_action_limit_service.dart b/lib/src/services/user_action_limit_service.dart index 2a1899e..ac184d3 100644 --- a/lib/src/services/user_action_limit_service.dart +++ b/lib/src/services/user_action_limit_service.dart @@ -25,8 +25,10 @@ abstract class UserActionLimitService { /// /// This method checks against `reactionsPerDay` and, if the engagement /// contains a comment, also checks against `commentsPerDay`. - Future checkEngagementCreationLimit( - {required User user, required Engagement engagement}); + Future checkEngagementCreationLimit({ + required User user, + required Engagement engagement, + }); /// Validates if a user can create a new [Report]. ///