From b7ac16aa5611fc20a493c592c575035fdf7ac88b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 15:44:02 +0100 Subject: [PATCH 01/10] build(deps): update core dependency - Update core dependency to latest version - Change git ref from 4ffe883 to a960fe8 --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 9bf3a80..21c7b2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,8 +181,8 @@ packages: dependency: "direct main" description: path: "." - ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" - resolved-ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" + ref: a960fe8f340fc8b74b651997de45aee10d8435aa + resolved-ref: a960fe8f340fc8b74b651997de45aee10d8435aa 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 c2e3790..59512bd 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: 4ffe8833b8a042b2b3b68550f682c5aa902a5ccc + ref: a960fe8f340fc8b74b651997de45aee10d8435aa http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git From 08d50cf3de061f2918df453cc152a88f6696ab1d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:27:50 +0100 Subject: [PATCH 02/10] chore: misc --- lib/src/config/model_registry.dart | 567 ----------------------------- 1 file changed, 567 deletions(-) delete mode 100644 lib/src/config/model_registry.dart diff --git a/lib/src/config/model_registry.dart b/lib/src/config/model_registry.dart deleted file mode 100644 index 67ea336..0000000 --- a/lib/src/config/model_registry.dart +++ /dev/null @@ -1,567 +0,0 @@ -// 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 61f1a3f68aa9073aadaf07e05d9c41520d5994ac Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:28:16 +0100 Subject: [PATCH 03/10] feat(rbac): add permissions for app review management Introduces a new set of permissions for the `AppReview` resource. This follows the established `resource.action_owned` pattern for user-generated content, defining `create`, `read`, and `update` permissions. These will be used to control user access to their own app review records. --- lib/src/rbac/permissions.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 6e43f56..69e00e7 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -123,4 +123,14 @@ abstract class Permissions { /// Allows reading the user's own reports. static const String reportReadOwned = 'report.read_owned'; + + // App Review Permissions (User-owned) + /// Allows creating an app review record. + static const String appReviewCreateOwned = 'app_review.create_owned'; + + /// Allows reading the user's own app review record. + static const String appReviewReadOwned = 'app_review.read_owned'; + + /// Allows updating the user's own app review record. + static const String appReviewUpdateOwned = 'app_review.update_owned'; } From 787d3a90dfcd08d1b517e1460aab9d81d76f76fe Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:28:32 +0100 Subject: [PATCH 04/10] feat(rbac): grant app review permissions to user roles Assigns the newly created `app_review.*_owned` permissions to all standard app user roles (`guestUser`, `standardUser`, `premiumUser`). This allows any authenticated user to create and manage their own app review feedback through the generic data API. --- lib/src/rbac/role_permissions.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 6c5288c..2bdb070 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -38,6 +38,11 @@ final Set _appGuestUserPermissions = { Permissions.engagementDeleteOwned, Permissions.reportCreateOwned, Permissions.reportReadOwned, + + // App Review Permissions + Permissions.appReviewCreateOwned, + Permissions.appReviewReadOwned, + Permissions.appReviewUpdateOwned, }; final Set _appStandardUserPermissions = { From 14e16616e8c0d05d59e3a3c33565a86341040d02 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:29:48 +0100 Subject: [PATCH 05/10] feat(api): register app_review model in the data API Adds a `ModelConfig` for the new `AppReview` model to the central model registry. This configuration defines it as a user-owned resource, mapping the `create`, `read`, and `update` actions to their corresponding `app_review.*_owned` permissions and enabling ownership checks. This makes the `AppReview` model accessible via the generic `/data` endpoint. --- lib/src/rbac/role_permissions.dart | 2 - lib/src/registry/model_registry.dart | 84 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 2bdb070..466a582 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -38,8 +38,6 @@ final Set _appGuestUserPermissions = { Permissions.engagementDeleteOwned, Permissions.reportCreateOwned, Permissions.reportReadOwned, - - // App Review Permissions Permissions.appReviewCreateOwned, Permissions.appReviewReadOwned, Permissions.appReviewUpdateOwned, diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index bf07f04..28d504b 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -500,6 +500,90 @@ final modelRegistry = >{ 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, + ), + ), + 'app_review': ModelConfig( + fromJson: AppReview.fromJson, + getId: (r) => r.id, + getOwnerId: (dynamic item) => (item as AppReview).userId, + // Collection GET is allowed for a user to fetch their own review record. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appReviewReadOwned, + requiresOwnershipCheck: true, + ), + // Item GET is allowed for a user to fetch their own review record. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appReviewReadOwned, + requiresOwnershipCheck: true, + ), + // POST is allowed for a user to create their initial review record. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appReviewCreateOwned, + ), + // PUT is allowed for a user to update their review record (e.g., add + // negative feedback history). + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appReviewUpdateOwned, + requiresOwnershipCheck: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + ), }; /// Type alias for the ModelRegistry map for easier provider usage. From de4990b1c6016eaba7adeeaf3d2d4d5fec497f8d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:30:17 +0100 Subject: [PATCH 06/10] feat(deps): initialize and provide app review repository Initializes a `DataMongodb` client and its corresponding `DataRepository`. This repository is then added to the `AppDependencies` provider, making it available for use by other services, such as the `UserActionLimitService`. --- lib/src/config/app_dependencies.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 1b6d1e4..a82b820 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -75,6 +75,7 @@ class AppDependencies { late final DataRepository engagementRepository; late final DataRepository reportRepository; + late final DataRepository appReviewRepository; late final EmailRepository emailRepository; // Services @@ -249,6 +250,14 @@ class AppDependencies { logger: Logger('DataMongodb'), ); + final appReviewClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'app_reviews', + fromJson: AppReview.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + // --- Conditionally Initialize Push Notification Clients --- // Firebase @@ -339,6 +348,7 @@ class AppDependencies { ); engagementRepository = DataRepository(dataClient: engagementClient); reportRepository = DataRepository(dataClient: reportClient); + appReviewRepository = DataRepository(dataClient: appReviewClient); // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add From ac77c04b2f1d16bb295bb9c07dbaa930189ceeed Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:30:29 +0100 Subject: [PATCH 07/10] feat(api): provide app review repository to request context Adds the newly created `DataRepository` to the root middleware's provider chain. This makes the repository accessible from the request context, allowing the generic data route handlers to perform CRUD operations on the `app_reviews` collection. --- routes/_middleware.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index b09a675..7679d10 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -139,6 +139,11 @@ Handler middleware(Handler handler) { (_) => deps.inAppNotificationRepository, ), ) + .use( + provider>( + (_) => deps.appReviewRepository, + ), + ) .use( provider( (_) => deps.pushNotificationService, From 01b34e33c91f6e2189aaadd68763f80ff79aa2b2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:38:34 +0100 Subject: [PATCH 08/10] feat(data): add app_review data operations - Register read and readAll operations for AppReview - Implement custom create operation with security check for AppReview - Add update and delete operations for AppReview --- lib/src/registry/data_operation_registry.dart | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 28b8c2e..4b011d3 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -132,6 +132,8 @@ class DataOperationRegistry { c.read>().read(id: id, userId: null), 'report': (c, id) => c.read>().read(id: id, userId: null), + 'app_review': (c, id) => + c.read>().read(id: id, userId: null), }); // --- Register "Read All" Readers --- @@ -208,6 +210,13 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'app_review': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -340,6 +349,21 @@ class DataOperationRegistry { return context.read>().create(item: item); }, + 'app_review': (context, item, uid) async { + _log.info('Executing custom creator for app_review.'); + final authenticatedUser = context.read(); + final userActionLimitService = context.read(); + final appReviewToCreate = item as AppReview; + + // Security Check + if (appReviewToCreate.userId != authenticatedUser.id) { + throw const ForbiddenException( + 'You can only create app reviews for your own account.', + ); + } + + return context.read>().create(item: item); + }, }); // --- Register Item Updaters --- @@ -509,6 +533,11 @@ class DataOperationRegistry { id: id, item: item as Report, ), + 'app_review': (c, id, item, uid) => + c.read>().update( + id: id, + item: item as AppReview, + ), }); // --- Register Item Deleters --- @@ -540,6 +569,8 @@ class DataOperationRegistry { c.read>().delete(id: id, userId: uid), 'report': (c, id, uid) => c.read>().delete(id: id, userId: uid), + 'app_review': (c, id, uid) => + c.read>().delete(id: id, userId: uid), }); } } From 93d9a1dc29b00841d6392d970e0a24b83dedf921 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:38:44 +0100 Subject: [PATCH 09/10] feat(database): add index for app_reviews collection - Add index on 'userId' field in app_reviews collection - Optimize fetching review record for a specific user - Ensure indexes for app_reviews collection are set up correctly --- lib/src/services/database_seeding_service.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 91345e4..7e77eff 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -313,6 +313,19 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "reports".'); + // Indexes for the app_reviews collection + await _db.runCommand({ + 'createIndexes': 'app_reviews', + 'indexes': [ + { + // Optimizes fetching the review record for a specific user. + 'key': {'userId': 1}, + 'name': 'userId_index', + }, + ], + }); + _log.info('Ensured indexes for "app_reviews".'); + _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { _log.severe('Failed to create database indexes.', e, s); From 9d90183c7af4b19f931dec795cb014348e0fed27 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 30 Nov 2025 16:52:04 +0100 Subject: [PATCH 10/10] refactor(data): remove unused service injection - Remove unused UserActionLimitService injection in app_review creator --- lib/src/registry/data_operation_registry.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index 4b011d3..822ab0c 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -352,7 +352,6 @@ class DataOperationRegistry { 'app_review': (context, item, uid) async { _log.info('Executing custom creator for app_review.'); final authenticatedUser = context.read(); - final userActionLimitService = context.read(); final appReviewToCreate = item as AppReview; // Security Check