From c09c3b31f00f893bafd4c6f1d17869dccc34e29e Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:29:59 +0100 Subject: [PATCH 01/48] refactor: ensures the RBAC system's permission definitions are in sync with the new resource names used throughout the API. --- lib/src/rbac/permissions.dart | 42 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index e0735c7..53687c0 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -10,11 +10,11 @@ abstract class Permissions { static const String headlineUpdate = 'headline.update'; static const String headlineDelete = 'headline.delete'; - // Category Permissions - static const String categoryCreate = 'category.create'; - static const String categoryRead = 'category.read'; - static const String categoryUpdate = 'category.update'; - static const String categoryDelete = 'category.delete'; + // Topic Permissions + static const String topicCreate = 'topic.create'; + static const String topicRead = 'topic.read'; + static const String topicUpdate = 'topic.update'; + static const String topicDelete = 'topic.delete'; // Source Permissions static const String sourceCreate = 'source.create'; @@ -38,20 +38,20 @@ abstract class Permissions { // Allows deleting the authenticated user's own account static const String userDeleteOwned = 'user.delete_owned'; - // App Settings Permissions (User-owned) - static const String appSettingsReadOwned = 'app_settings.read_owned'; - static const String appSettingsUpdateOwned = 'app_settings.update_owned'; - - // User Preferences Permissions (User-owned) - static const String userPreferencesReadOwned = 'user_preferences.read_owned'; - static const String userPreferencesUpdateOwned = - 'user_preferences.update_owned'; - - // App Config Permissions (Global/Managed) - static const String appConfigCreate = 'app_config.create'; - static const String appConfigRead = 'app_config.read'; - static const String appConfigUpdate = 'app_config.update'; - static const String appConfigDelete = 'app_config.delete'; - - // Add other permissions as needed for future models/features + // User App Settings Permissions (User-owned) + static const String userAppSettingsReadOwned = 'user_app_settings.read_owned'; + static const String userAppSettingsUpdateOwned = + 'user_app_settings.update_owned'; + + // User Content Preferences Permissions (User-owned) + static const String userContentPreferencesReadOwned = + 'user_content_preferences.read_owned'; + static const String userContentPreferencesUpdateOwned = + 'user_content_preferences.update_owned'; + + // Remote Config Permissions (Global/Managed) + static const String remoteConfigCreate = 'remote_config.create'; + static const String remoteConfigRead = 'remote_config.read'; + static const String remoteConfigUpdate = 'remote_config.update'; + static const String remoteConfigDelete = 'remote_config.delete'; } From dd5af062e085f651218021417b718eaa95c095ca Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:31:23 +0100 Subject: [PATCH 02/48] refactor: Replaces the old string-based role-to-permission mapping with a new Map>. This map uses the AppUserRole and DashboardUserRole enums as keys, directly linking the new user model structure to the RBAC system --- lib/src/rbac/role_permissions.dart | 82 +++++++++++++++--------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 7a17895..4830210 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -1,70 +1,70 @@ import 'package:ht_api/src/rbac/permissions.dart'; import 'package:ht_shared/ht_shared.dart'; -final Set _guestUserPermissions = { +// --- App Role Permissions --- + +final Set _appGuestUserPermissions = { Permissions.headlineRead, - Permissions.categoryRead, + Permissions.topicRead, Permissions.sourceRead, Permissions.countryRead, - Permissions.appSettingsReadOwned, - Permissions.appSettingsUpdateOwned, - Permissions.userPreferencesReadOwned, - Permissions.userPreferencesUpdateOwned, - Permissions.appConfigRead, + Permissions.userAppSettingsReadOwned, + Permissions.userAppSettingsUpdateOwned, + Permissions.userContentPreferencesReadOwned, + Permissions.userContentPreferencesUpdateOwned, + Permissions.remoteConfigRead, }; -final Set _standardUserPermissions = { - ..._guestUserPermissions, +final Set _appStandardUserPermissions = { + ..._appGuestUserPermissions, Permissions.userReadOwned, Permissions.userUpdateOwned, Permissions.userDeleteOwned, }; -// For now, premium users have the same permissions as standard users, -// but this set can be expanded later for premium-specific features. -final Set _premiumUserPermissions = {..._standardUserPermissions}; +final Set _appPremiumUserPermissions = { + ..._appStandardUserPermissions, + // Future premium-only permissions can be added here. +}; + +// --- Dashboard Role Permissions --- -final Set _publisherPermissions = { - ..._standardUserPermissions, +final Set _dashboardPublisherPermissions = { Permissions.headlineCreate, Permissions.headlineUpdate, Permissions.headlineDelete, }; -final Set _adminPermissions = { - ..._standardUserPermissions, - Permissions.headlineCreate, - Permissions.headlineUpdate, - Permissions.headlineDelete, - Permissions.categoryCreate, - Permissions.categoryUpdate, - Permissions.categoryDelete, +final Set _dashboardAdminPermissions = { + ..._dashboardPublisherPermissions, + Permissions.topicCreate, + Permissions.topicUpdate, + Permissions.topicDelete, Permissions.sourceCreate, Permissions.sourceUpdate, Permissions.sourceDelete, Permissions.countryCreate, Permissions.countryUpdate, Permissions.countryDelete, - Permissions.userRead, - Permissions.appConfigCreate, - Permissions.appConfigUpdate, - Permissions.appConfigDelete, + Permissions.userRead, // Allows reading any user's profile + Permissions.remoteConfigCreate, + Permissions.remoteConfigUpdate, + Permissions.remoteConfigDelete, }; -/// Defines the mapping between user roles and the permissions they possess. -/// -/// This map is the core of the Role-Based Access Control (RBAC) system. -/// Each key is a role string, and the associated value is a [Set] of -/// [Permissions] strings that users with that role are granted. +/// Defines the mapping between user roles (both app and dashboard) and the +/// permissions they possess. /// -/// Note: Administrators typically have implicit access to all resources -/// regardless of this map, but including their permissions here can aid -/// documentation and clarity. The `PermissionService` should handle the -/// explicit admin bypass if desired. -final Map> rolePermissions = { - UserRoles.guestUser: _guestUserPermissions, - UserRoles.standardUser: _standardUserPermissions, - UserRoles.premiumUser: _premiumUserPermissions, - UserRoles.publisher: _publisherPermissions, - UserRoles.admin: _adminPermissions, +/// The `PermissionService` will look up a user's `appRole` and +/// `dashboardRole` in this map and combine the resulting permission sets to +/// determine their total access rights. +final Map> rolePermissions = { + // App Roles + AppUserRole.guestUser: _appGuestUserPermissions, + AppUserRole.standardUser: _appStandardUserPermissions, + AppUserRole.premiumUser: _appPremiumUserPermissions, + // Dashboard Roles + DashboardUserRole.none: {}, + DashboardUserRole.publisher: _dashboardPublisherPermissions, + DashboardUser-Role.admin: _dashboardAdminPermissions, }; From 753aaf96df3dc1dbb23772a29b5f4292e98f6a92 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:32:09 +0100 Subject: [PATCH 03/48] fix(rbac): correct constant name in role permissions map - Fixed typo in DashboardUserRole enum value - Changed 'DashboardUser-Role.admin' to 'DashboardUserRole.admin' - This correction ensures proper mapping of admin permissions to the correct role --- lib/src/rbac/role_permissions.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 4830210..9e26b9e 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -66,5 +66,5 @@ final Map> rolePermissions = { // Dashboard Roles DashboardUserRole.none: {}, DashboardUserRole.publisher: _dashboardPublisherPermissions, - DashboardUser-Role.admin: _dashboardAdminPermissions, + DashboardUserRole.admin: _dashboardAdminPermissions, }; From 4e79d3a7cc9bfabd04caf5e108024c0e685a9d2c Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:32:52 +0100 Subject: [PATCH 04/48] refactor(rbac): enhance permission checking logic - Update hasPermission method to consider both appRole and dashboardRole - Modify isAdmin method to check dashboardRole directly - Improve documentation to reflect new logic and role separation --- lib/src/rbac/permission_service.dart | 31 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/lib/src/rbac/permission_service.dart b/lib/src/rbac/permission_service.dart index 63561a2..4411631 100644 --- a/lib/src/rbac/permission_service.dart +++ b/lib/src/rbac/permission_service.dart @@ -4,9 +4,10 @@ import 'package:ht_shared/ht_shared.dart'; /// {@template permission_service} /// Service responsible for checking if a user has a specific permission. /// -/// This service uses the predefined [rolePermissions] map to determine -/// a user's access rights based on their roles. It also includes -/// an explicit check for the 'admin' role, granting them all permissions. +/// This service uses the predefined [rolePermissions] map to determine a user's +/// access rights based on their `appRole` and `dashboardRole`. It also +/// includes an explicit check for the `admin` role, granting them all +/// permissions. /// {@endtemplate} class PermissionService { /// {@macro permission_service} @@ -14,30 +15,36 @@ class PermissionService { /// Checks if the given [user] has the specified [permission]. /// - /// Returns `true` if the user's role grants the permission, or if the user - /// is an administrator. Returns `false` otherwise. + /// Returns `true` if the user's combined roles grant the permission, or if + /// the user is an administrator. Returns `false` otherwise. /// /// - [user]: The authenticated user. /// - [permission]: The permission string to check (e.g., `headline.read`). bool hasPermission(User user, String permission) { // Administrators implicitly have all permissions. - if (user.roles.contains(UserRoles.admin)) { + if (isAdmin(user)) { return true; } - // Check if any of the user's roles grant the required permission. - return user.roles.any( - (role) => rolePermissions[role]?.contains(permission) ?? false, - ); + // Get the permission sets for the user's app and dashboard roles. + final appPermissions = rolePermissions[user.appRole] ?? const {}; + final dashboardPermissions = + rolePermissions[user.dashboardRole] ?? const {}; + + // Combine the permissions from both roles. + final totalPermissions = {...appPermissions, ...dashboardPermissions}; + + // Check if the combined set contains the required permission. + return totalPermissions.contains(permission); } - /// Checks if the given [user] has the 'admin' role. + /// Checks if the given [user] has the `admin` dashboard role. /// /// This is a convenience method for checks that are strictly limited /// to administrators, bypassing the permission map. /// /// - [user]: The authenticated user. bool isAdmin(User user) { - return user.roles.contains(UserRoles.admin); + return user.dashboardRole == DashboardUserRole.admin; } } From 7f6491276a09eefccf2d3dfcf3afaa8584a727aa Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:34:02 +0100 Subject: [PATCH 05/48] feat(auth): update JWT claims for enum-based roles - Replace 'roles' claim with 'appRole' and 'dashboardRole' claims - Use enum .name property for role string values - Keep 'email' claim for convenience --- lib/src/services/jwt_auth_token_service.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index ea4fb24..32a237e 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -59,8 +59,10 @@ class JwtAuthTokenService implements AuthTokenService { 'iss': _issuer, // Issuer 'jti': _uuid.v4(), // JWT ID (for potential blacklisting) // Custom claims (optional, include what's useful) - 'email': user.email, - 'roles': user.roles, // Include the user's roles as a list of strings + 'email': user.email, // Kept for convenience + // Embed the new enum-based roles. Use .name for string value. + 'appRole': user.appRole.name, + 'dashboardRole': user.dashboardRole.name, }, issuer: _issuer, subject: user.id, From 3af92e3df891f39d3f4008a151375870e914d02a Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:35:08 +0100 Subject: [PATCH 06/48] refactor(auth): upgrade anonymous user linking process - Update user creation flow for both registered and anonymous users - Enhance user object with additional properties and default values - Improve error handling and validation for account linking process - Replace role checks with more specific appRole comparisons --- lib/src/services/auth_service.dart | 48 ++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 34e2e21..15ecf6e 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -169,9 +169,21 @@ class AuthService { // All new users created via the public API get the standard role. // Admin users must be provisioned out-of-band (e.g., via fixtures). - final roles = [UserRoles.standardUser]; - - user = User(id: _uuid.v4(), email: email, roles: roles); + user = User( + id: _uuid.v4(), + email: email, + appRole: AppUserRole.standardUser, + dashboardRole: DashboardUserRole.none, + createdAt: DateTime.now(), + feedActionStatus: Map.fromEntries( + FeedActionType.values.map( + (type) => MapEntry( + type, + const UserFeedActionStatus(isCompleted: false), + ), + ), + ), + ); user = await _userRepository.create(item: user); print('Created new user: ${user.id} with roles: ${user.roles}'); @@ -224,9 +236,21 @@ class AuthService { User user; try { user = User( - id: _uuid.v4(), // Generate new ID - roles: const [UserRoles.guestUser], // Anonymous users are guest users - email: null, // Anonymous users don't have an email initially + id: _uuid.v4(), + // Use a unique placeholder email for anonymous users to satisfy the + // non-nullable email constraint. + email: '${_uuid.v4()}@anonymous.com', + appRole: AppUserRole.guestUser, + dashboardRole: DashboardUserRole.none, + createdAt: DateTime.now(), + feedActionStatus: Map.fromEntries( + FeedActionType.values.map( + (type) => MapEntry( + type, + const UserFeedActionStatus(isCompleted: false), + ), + ), + ), ); user = await _userRepository.create(item: user); print('Created anonymous user: ${user.id}'); @@ -335,7 +359,7 @@ class AuthService { required User anonymousUser, required String emailToLink, }) async { - if (!anonymousUser.roles.contains(UserRoles.guestUser)) { + if (anonymousUser.appRole != AppUserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot link email.', ); @@ -348,8 +372,7 @@ class AuthService { // Filter for permanent users (not guests) that are not the current user. final conflictingPermanentUsers = existingUsersResponse.items.where( - (u) => - !u.roles.contains(UserRoles.guestUser) && u.id != anonymousUser.id, + (u) => u.appRole != AppUserRole.guestUser && u.id != anonymousUser.id, ); if (conflictingPermanentUsers.isNotEmpty) { @@ -399,7 +422,7 @@ class AuthService { required String codeFromUser, required String oldAnonymousToken, // Needed to invalidate it }) async { - if (!anonymousUser.roles.contains(UserRoles.guestUser)) { + if (anonymousUser.appRole != AppUserRole.guestUser) { // Should ideally not happen if flow is correct, but good safeguard. throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', @@ -421,10 +444,9 @@ class AuthService { } // 2. Update the user to be permanent. - final updatedUser = User( - id: anonymousUser.id, // Preserve original ID + final updatedUser = anonymousUser.copyWith( email: linkedEmail, - roles: const [UserRoles.standardUser], // Now a permanent standard user + appRole: AppUserRole.standardUser, ); final permanentUser = await _userRepository.update( id: updatedUser.id, From f038b3a6cbd770ed305af1c8d8b55703ec62b01a Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:35:57 +0100 Subject: [PATCH 07/48] fix(auth): update user role checks and registration output - Change role check to use dashboardRole instead of roles list - Update new user creation output to show appRole instead of roles - Improve code readability by using separate lines for output messages --- lib/src/services/auth_service.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 15ecf6e..3855bce 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -73,9 +73,8 @@ class AuthService { ); } - final hasRequiredRole = - user.roles.contains(UserRoles.admin) || - user.roles.contains(UserRoles.publisher); + final hasRequiredRole = user.dashboardRole == DashboardUserRole.admin || + user.dashboardRole == DashboardUserRole.publisher; if (!hasRequiredRole) { print( @@ -185,7 +184,9 @@ class AuthService { ), ); user = await _userRepository.create(item: user); - print('Created new user: ${user.id} with roles: ${user.roles}'); + print( + 'Created new user: ${user.id} with appRole: ${user.appRole}', + ); // Create default UserAppSettings for the new user final defaultAppSettings = UserAppSettings(id: user.id); From 2832ed5a92711e99e5f62db76c2cd89e057dd1f0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:36:31 +0100 Subject: [PATCH 08/48] refactor(dashboard): replace Category with Topic in DashboardSummaryService - Update DashboardSummaryService to use HtDataRepository instead of HtDataRepository - Modify the service to fetch topics instead of categories - Update the returned DashboardSummary to use topicCount instead of categoryCount --- lib/src/services/dashboard_summary_service.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/services/dashboard_summary_service.dart b/lib/src/services/dashboard_summary_service.dart index 5053c06..ba9bfdc 100644 --- a/lib/src/services/dashboard_summary_service.dart +++ b/lib/src/services/dashboard_summary_service.dart @@ -11,14 +11,14 @@ class DashboardSummaryService { /// {@macro dashboard_summary_service} const DashboardSummaryService({ required HtDataRepository headlineRepository, - required HtDataRepository categoryRepository, + required HtDataRepository topicRepository, required HtDataRepository sourceRepository, }) : _headlineRepository = headlineRepository, - _categoryRepository = categoryRepository, + _topicRepository = topicRepository, _sourceRepository = sourceRepository; final HtDataRepository _headlineRepository; - final HtDataRepository _categoryRepository; + final HtDataRepository _topicRepository; final HtDataRepository _sourceRepository; /// Calculates and returns the current dashboard summary. @@ -29,19 +29,19 @@ class DashboardSummaryService { // Use Future.wait to fetch all counts in parallel for efficiency. final results = await Future.wait([ _headlineRepository.readAll(), - _categoryRepository.readAll(), + _topicRepository.readAll(), _sourceRepository.readAll(), ]); // The results are PaginatedResponse objects. final headlineResponse = results[0] as PaginatedResponse; - final categoryResponse = results[1] as PaginatedResponse; + final topicResponse = results[1] as PaginatedResponse; final sourceResponse = results[2] as PaginatedResponse; return DashboardSummary( id: 'dashboard_summary', // Fixed ID for the singleton summary headlineCount: headlineResponse.items.length, - categoryCount: categoryResponse.items.length, + topicCount: topicResponse.items.length, sourceCount: sourceResponse.items.length, ); } From cdb98e0ad906230f8926ebde7bb9b03d95a7932b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:37:49 +0100 Subject: [PATCH 09/48] refactor(user-preferences): update limit enforcement logic and dependencies - Replace AppConfig with RemoteConfig for fetching user preference limits - Remove unused UserContentPreferencesRepository dependency - Simplify user role checking by using dashboardRole instead of roles - Update error handling and messaging - Rename `followedCategories` to `followedTopics` in error message --- ...default_user_preference_limit_service.dart | 103 ++++++++---------- 1 file changed, 47 insertions(+), 56 deletions(-) diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index cc10281..0f382b1 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -4,19 +4,18 @@ import 'package:ht_shared/ht_shared.dart'; /// {@template default_user_preference_limit_service} /// Default implementation of [UserPreferenceLimitService] that enforces limits -/// based on user role and [AppConfig]. +/// based on user role and [RemoteConfig]. /// {@endtemplate} class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { /// {@macro default_user_preference_limit_service} const DefaultUserPreferenceLimitService({ - required HtDataRepository appConfigRepository, - // Removed unused UserContentPreferencesRepository - }) : _appConfigRepository = appConfigRepository; + required HtDataRepository remoteConfigRepository, + }) : _remoteConfigRepository = remoteConfigRepository; - final HtDataRepository _appConfigRepository; + final HtDataRepository _remoteConfigRepository; - // Assuming a fixed ID for the AppConfig document - static const String _appConfigId = 'app_config'; + // Assuming a fixed ID for the RemoteConfig document + static const String _remoteConfigId = 'remote_config'; @override Future checkAddItem( @@ -25,39 +24,35 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { int currentCount, ) async { try { - // 1. Fetch the application configuration to get limits - final appConfig = await _appConfigRepository.read(id: _appConfigId); - final limits = appConfig.userPreferenceLimits; + // 1. Fetch the remote configuration to get limits + final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final limits = remoteConfig.userPreferenceLimits; // Admins have no limits. - if (user.roles.contains(UserRoles.admin)) { + if (user.dashboardRole == DashboardUserRole.admin) { return; } - // 2. Determine the limit based on the user's highest role. + // 2. Determine the limit based on the user's app role. int limit; String accountType; - if (user.roles.contains(UserRoles.premiumUser)) { - accountType = 'premium'; - limit = (itemType == 'headline') - ? limits.premiumSavedHeadlinesLimit - : limits.premiumFollowedItemsLimit; - } else if (user.roles.contains(UserRoles.standardUser)) { - accountType = 'standard'; - limit = (itemType == 'headline') - ? limits.authenticatedSavedHeadlinesLimit - : limits.authenticatedFollowedItemsLimit; - } else if (user.roles.contains(UserRoles.guestUser)) { - accountType = 'guest'; - limit = (itemType == 'headline') - ? limits.guestSavedHeadlinesLimit - : limits.guestFollowedItemsLimit; - } else { - // Fallback for users with unknown or no roles. - throw const ForbiddenException( - 'Cannot determine preference limits for this user account.', - ); + switch (user.appRole) { + case AppUserRole.premiumUser: + accountType = 'premium'; + limit = (itemType == 'headline') + ? limits.premiumSavedHeadlinesLimit + : limits.premiumFollowedItemsLimit; + case AppUserRole.standardUser: + accountType = 'standard'; + limit = (itemType == 'headline') + ? limits.authenticatedSavedHeadlinesLimit + : limits.authenticatedFollowedItemsLimit; + case AppUserRole.guestUser: + accountType = 'guest'; + limit = (itemType == 'headline') + ? limits.guestSavedHeadlinesLimit + : limits.guestFollowedItemsLimit; } // 3. Check if adding the item would exceed the limit @@ -85,37 +80,33 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { UserContentPreferences updatedPreferences, ) async { try { - // 1. Fetch the application configuration to get limits - final appConfig = await _appConfigRepository.read(id: _appConfigId); - final limits = appConfig.userPreferenceLimits; + // 1. Fetch the remote configuration to get limits + final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final limits = remoteConfig.userPreferenceLimits; // Admins have no limits. - if (user.roles.contains(UserRoles.admin)) { + if (user.dashboardRole == DashboardUserRole.admin) { return; } - // 2. Determine limits based on the user's highest role. + // 2. Determine limits based on the user's app role. int followedItemsLimit; int savedHeadlinesLimit; String accountType; - if (user.roles.contains(UserRoles.premiumUser)) { - accountType = 'premium'; - followedItemsLimit = limits.premiumFollowedItemsLimit; - savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; - } else if (user.roles.contains(UserRoles.standardUser)) { - accountType = 'standard'; - followedItemsLimit = limits.authenticatedFollowedItemsLimit; - savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; - } else if (user.roles.contains(UserRoles.guestUser)) { - accountType = 'guest'; - followedItemsLimit = limits.guestFollowedItemsLimit; - savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; - } else { - // Fallback for users with unknown or no roles. - throw const ForbiddenException( - 'Cannot determine preference limits for this user account.', - ); + switch (user.appRole) { + case AppUserRole.premiumUser: + accountType = 'premium'; + followedItemsLimit = limits.premiumFollowedItemsLimit; + savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; + case AppUserRole.standardUser: + accountType = 'standard'; + followedItemsLimit = limits.authenticatedFollowedItemsLimit; + savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; + case AppUserRole.guestUser: + accountType = 'guest'; + followedItemsLimit = limits.guestFollowedItemsLimit; + savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; } // 3. Check if proposed preferences exceed limits @@ -131,9 +122,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { 'for your account type ($accountType).', ); } - if (updatedPreferences.followedCategories.length > followedItemsLimit) { + if (updatedPreferences.followedTopics.length > followedItemsLimit) { throw ForbiddenException( - 'You have reached the maximum number of followed categories allowed ' + 'You have reached the maximum number of followed topics allowed ' 'for your account type ($accountType).', ); } From 587cb6e730f9336b2b5a06b6684be0c1d172eae7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:38:46 +0100 Subject: [PATCH 10/48] feat(database): update database schema for new features - Rename tables and columns to better reflect their content and usage - Add new columns for upcoming features like ad config and account action config - Remove unused columns to simplify the schema - Adjust foreign key relationships to match new table names """ Changed database schema to support new features and improve data organization. Here are the key changes: - Renamed 'app_config' to 'remote_config' to clarify its purpose - Renamed 'categories' to 'topics' to better reflect the content - Removed 'type' columns from multiple tables as they were no longer used - Added new columns to 'remote_config' for ad config, account action config, and app status - Changed 'categories' to 'topics' in the 'headlines' table - Removed 'last_engagement_shown_at' from 'users' table - Updated table and column names to align with new feature requirements --- .../services/database_seeding_service.dart | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 57eab06..3d749d8 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -39,32 +39,35 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, - email TEXT UNIQUE, - roles JSONB NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_engagement_shown_at TIMESTAMPTZ + email TEXT NOT NULL UNIQUE, + app_role TEXT NOT NULL, + dashboard_role TEXT NOT NULL, + feed_action_status JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); '''); - _log.fine('Creating "app_config" table...'); + _log.fine('Creating "remote_config" table...'); await _connection.execute(''' - CREATE TABLE IF NOT EXISTS app_config ( + CREATE TABLE IF NOT EXISTS remote_config ( id TEXT PRIMARY KEY, user_preference_limits JSONB NOT NULL, + ad_config JSONB NOT NULL, + account_action_config JSONB NOT NULL, + app_status JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); '''); - _log.fine('Creating "categories" table...'); + _log.fine('Creating "topics" table...'); await _connection.execute(''' - CREATE TABLE IF NOT EXISTS categories ( + CREATE TABLE IF NOT EXISTS topics ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, icon_url TEXT, status TEXT, - type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -79,7 +82,6 @@ class DatabaseSeedingService { url TEXT, language TEXT, status TEXT, - type TEXT, source_type TEXT, headquarters_country_id TEXT REFERENCES countries(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -95,7 +97,6 @@ class DatabaseSeedingService { iso_code TEXT NOT NULL UNIQUE, flag_url TEXT NOT NULL, status TEXT, - type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -106,16 +107,15 @@ class DatabaseSeedingService { CREATE TABLE IF NOT EXISTS headlines ( id TEXT PRIMARY KEY, title TEXT NOT NULL, - source_id TEXT REFERENCES sources(id), - category_id TEXT REFERENCES categories(id), - image_url TEXT, + excerpt TEXT, url TEXT, - published_at TIMESTAMPTZ, - description TEXT, + image_url TEXT, + source_id TEXT REFERENCES sources(id), + topic_id TEXT REFERENCES topics(id), + event_country_id TEXT REFERENCES countries(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, status TEXT, - type TEXT ); '''); @@ -127,8 +127,6 @@ class DatabaseSeedingService { display_settings JSONB NOT NULL, -- Nested object, stored as JSON language TEXT NOT NULL, -- Simple string, stored as TEXT feed_preferences JSONB NOT NULL, - engagement_shown_counts JSONB NOT NULL, - engagement_last_shown_timestamps JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -139,7 +137,7 @@ class DatabaseSeedingService { CREATE TABLE IF NOT EXISTS user_content_preferences ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - followed_categories JSONB NOT NULL, + followed_topics JSONB NOT NULL, followed_sources JSONB NOT NULL, followed_countries JSONB NOT NULL, saved_headlines JSONB NOT NULL, From b7954edc703c6b7d871541cddbc78840817e84f8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:39:56 +0100 Subject: [PATCH 11/48] refactor(db): update seeding process and SQL queries - Rename 'categories' to 'topics' in seeding process - Remove 'type' field from countries, sources, and headlines tables - Remove 'category' and 'event_country' objects from headlines seeding - Update SQL queries to reflect schema changes --- .../services/database_seeding_service.dart | 60 +++++++++---------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 3d749d8..53cfb6e 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -171,25 +171,22 @@ class DatabaseSeedingService { try { await _connection.execute('BEGIN'); try { - // Seed Categories - _log.fine('Seeding categories...'); - for (final data in categoriesFixturesData) { - final category = Category.fromJson(data); - final params = category.toJson(); + // Seed Topics + _log.fine('Seeding topics...'); + for (final data in topicsFixturesData) { + final topic = Topic.fromJson(data); + final params = topic.toJson(); // Ensure optional fields exist for the postgres driver. - // The driver requires all named parameters to be present in the map, - // even if the value is null. The model's `toJson` with - // `includeIfNull: false` will omit keys for null fields. params.putIfAbsent('description', () => null); params.putIfAbsent('icon_url', () => null); params.putIfAbsent('updated_at', () => null); await _connection.execute( Sql.named( - 'INSERT INTO categories (id, name, description, icon_url, ' - 'status, type, created_at, updated_at) VALUES (@id, @name, @description, ' - '@icon_url, @status, @type, @created_at, @updated_at) ' + 'INSERT INTO topics (id, name, description, icon_url, ' + 'status, created_at, updated_at) VALUES (@id, @name, ' + '@description, @icon_url, @status, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, @@ -207,9 +204,9 @@ class DatabaseSeedingService { await _connection.execute( Sql.named( - 'INSERT INTO countries (id, name, iso_code, flag_url, status, ' - 'type, created_at, updated_at) VALUES (@id, @name, @iso_code, ' - '@flag_url, @status, @type, @created_at, @updated_at) ' + 'INSERT INTO countries (id, name, iso_code, flag_url, ' + 'status, created_at, updated_at) VALUES (@id, @name, ' + '@iso_code, @flag_url, @status, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, @@ -235,16 +232,14 @@ class DatabaseSeedingService { params.putIfAbsent('url', () => null); params.putIfAbsent('language', () => null); params.putIfAbsent('source_type', () => null); - params.putIfAbsent('status', () => null); - params.putIfAbsent('type', () => null); params.putIfAbsent('updated_at', () => null); await _connection.execute( Sql.named( 'INSERT INTO sources (id, name, description, url, language, ' - 'status, type, source_type, headquarters_country_id, ' - 'created_at, updated_at) VALUES (@id, @name, @description, ' - '@url, @language, @status, @type, @source_type, ' + 'status, source_type, headquarters_country_id, ' + 'created_at, updated_at) VALUES (@id, @name, @description, @url, ' + '@language, @status, @source_type, ' '@headquarters_country_id, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), @@ -257,28 +252,27 @@ class DatabaseSeedingService { for (final data in headlinesFixturesData) { final headline = Headline.fromJson(data); final params = headline.toJson(); - + // Extract IDs from nested objects and remove the objects to match schema. - // These are now nullable to match the schema. - params['source_id'] = headline.source?.id; - params['category_id'] = headline.category?.id; + params['source_id'] = headline.source.id; + params['topic_id'] = headline.topic.id; + params['event_country_id'] = headline.eventCountry.id; params.remove('source'); - params.remove('category'); - + params.remove('topic'); + params.remove('eventCountry'); + // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('description', () => null); + params.putIfAbsent('excerpt', () => null); params.putIfAbsent('updated_at', () => null); params.putIfAbsent('image_url', () => null); params.putIfAbsent('url', () => null); - params.putIfAbsent('published_at', () => null); - + await _connection.execute( Sql.named( - 'INSERT INTO headlines (id, title, source_id, category_id, ' - 'image_url, url, published_at, description, status, ' - 'type, created_at, updated_at) VALUES (@id, @title, @source_id, ' - '@category_id, @image_url, @url, @published_at, @description, ' - '@status, @type, @created_at, @updated_at) ' + 'INSERT INTO headlines (id, title, excerpt, url, image_url, ' + 'source_id, topic_id, event_country_id, status, created_at, ' + 'updated_at) VALUES (@id, @title, @excerpt, @url, @image_url, ' + '@source_id, @topic_id, @event_country_id, @status, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, From 33cab0817418afecd17e4719f5a7d116710ede7b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:48:00 +0100 Subject: [PATCH 12/48] refactor(database): update seeding process and data handling - Replace AppConfig with RemoteConfig to accommodate multiple JSONB columns - Update admin user seeding to reflect new User model structure - Improve error handling and logging - Refactor JSONB data insertion to ensure proper encoding --- .../services/database_seeding_service.dart | 107 ++++++++++++------ 1 file changed, 71 insertions(+), 36 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 53cfb6e..bc239de 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -295,90 +295,125 @@ class DatabaseSeedingService { } } - /// Seeds the database with the initial AppConfig and the default admin user. + /// Seeds the database with the initial RemoteConfig and the default admin user. /// /// This method is idempotent, using `ON CONFLICT DO NOTHING` to prevent /// errors if the data already exists. It runs within a single transaction. Future seedInitialAdminAndConfig() async { - _log.info('Seeding initial AppConfig and admin user...'); + _log.info('Seeding initial RemoteConfig and admin user...'); try { await _connection.execute('BEGIN'); try { - // Seed AppConfig - _log.fine('Seeding AppConfig...'); - final appConfig = AppConfig.fromJson(appConfigFixtureData); - // The `app_config` table only has `id` and `user_preference_limits`. - // We must provide an explicit map to avoid a "superfluous variables" - // error from the postgres driver. + // Seed RemoteConfig + _log.fine('Seeding RemoteConfig...'); + final remoteConfig = RemoteConfig.fromJson(remoteConfigFixtureData); + // The `remote_config` table has multiple JSONB columns. We must + // provide an explicit map with JSON-encoded values to avoid a + // "superfluous variables" error from the postgres driver. await _connection.execute( Sql.named( - 'INSERT INTO app_config (id, user_preference_limits) ' - 'VALUES (@id, @user_preference_limits) ' + 'INSERT INTO remote_config (id, user_preference_limits, ad_config, ' + 'account_action_config, app_status) VALUES (@id, ' + '@user_preference_limits, @ad_config, @account_action_config, ' + '@app_status) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: { - 'id': appConfig.id, - 'user_preference_limits': appConfig.userPreferenceLimits.toJson(), + 'id': remoteConfig.id, + 'user_preference_limits': + jsonEncode(remoteConfig.userPreferenceLimits.toJson()), + 'ad_config': jsonEncode(remoteConfig.adConfig.toJson()), + 'account_action_config': + jsonEncode(remoteConfig.accountActionConfig.toJson()), + 'app_status': jsonEncode(remoteConfig.appStatus.toJson()), }, ); // Seed Admin User _log.fine('Seeding admin user...'); // Find the admin user in the fixture data. - final adminUser = usersFixturesData.firstWhere( - (user) => user.roles.contains(UserRoles.admin), + final adminUserData = usersFixturesData.firstWhere( + (data) => data['dashboard_role'] == DashboardUserRole.admin.name, orElse: () => throw StateError('Admin user not found in fixtures.'), ); - // The `users` table only has `id`, `email`, and `roles`. We must - // provide an explicit map to avoid a "superfluous variables" error. + final adminUser = User.fromJson(adminUserData); + + // The `users` table has specific columns for roles and status. await _connection.execute( Sql.named( - 'INSERT INTO users (id, email, roles) ' - 'VALUES (@id, @email, @roles) ' + 'INSERT INTO users (id, email, app_role, dashboard_role, ' + 'feed_action_status) VALUES (@id, @email, @app_role, ' + '@dashboard_role, @feed_action_status) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: { 'id': adminUser.id, 'email': adminUser.email, - 'roles': jsonEncode(adminUser.roles), + 'app_role': adminUser.appRole.name, + 'dashboard_role': adminUser.dashboardRole.name, + 'feed_action_status': jsonEncode( + adminUser.feedActionStatus + .map((key, value) => MapEntry(key.name, value.toJson())), + ), }, ); // Seed default settings and preferences for the admin user. - final adminSettings = UserAppSettings(id: adminUser.id); - final adminPreferences = UserContentPreferences(id: adminUser.id); + final adminSettings = UserAppSettings.fromJson( + userAppSettingsFixturesData + .firstWhere((data) => data['id'] == adminUser.id), + ); + final adminPreferences = UserContentPreferences.fromJson( + userContentPreferencesFixturesData + .firstWhere((data) => data['id'] == adminUser.id), + ); await _connection.execute( Sql.named( 'INSERT INTO user_app_settings (id, user_id, display_settings, ' - 'language, feed_preferences, engagement_shown_counts, ' - 'engagement_last_shown_timestamps) VALUES (@id, @user_id, ' - '@display_settings, @language, @feed_preferences, ' - '@engagement_shown_counts, @engagement_last_shown_timestamps) ' + 'language, feed_preferences) VALUES (@id, @user_id, ' + '@display_settings, @language, @feed_preferences) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminSettings.toJson() - ..['user_id'] = adminUser.id, + parameters: { + 'id': adminSettings.id, + 'user_id': adminUser.id, + 'display_settings': + jsonEncode(adminSettings.displaySettings.toJson()), + 'language': adminSettings.language, + 'feed_preferences': + jsonEncode(adminSettings.feedPreferences.toJson()), + }, ); await _connection.execute( Sql.named( 'INSERT INTO user_content_preferences (id, user_id, ' - 'followed_categories, followed_sources, followed_countries, ' - 'saved_headlines) VALUES (@id, @user_id, @followed_categories, ' + 'followed_topics, followed_sources, followed_countries, ' + 'saved_headlines) VALUES (@id, @user_id, @followed_topics, ' '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - // Use toJson() to correctly serialize the lists of complex objects - // into a format the database driver can handle for JSONB columns. - parameters: adminPreferences.toJson() - ..['user_id'] = adminUser.id, + parameters: { + 'id': adminPreferences.id, + 'user_id': adminUser.id, + 'followed_topics': jsonEncode( + adminPreferences.followedTopics.map((e) => e.toJson()).toList(), + ), + 'followed_sources': jsonEncode( + adminPreferences.followedSources.map((e) => e.toJson()).toList(), + ), + 'followed_countries': jsonEncode( + adminPreferences.followedCountries.map((e) => e.toJson()).toList(), + ), + 'saved_headlines': jsonEncode( + adminPreferences.savedHeadlines.map((e) => e.toJson()).toList(), + ), + }, ); await _connection.execute('COMMIT'); - _log.info( - 'Initial AppConfig and admin user seeding completed successfully.', - ); + _log.info('Initial RemoteConfig and admin user seeding completed.'); } catch (e) { await _connection.execute('ROLLBACK'); rethrow; From ef5ee160aa7c99ddac222ef868554df17529b33e Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:51:41 +0100 Subject: [PATCH 13/48] refactor(dependencies): simplify repository configurations - Extract timestamp conversion to shared function - Simplify foreign key handling in serializers - Rename category to topic for better clarity - Update repository names and configurations - Refactor user and content preferences handling - Rename and expand remote config functionality --- lib/src/config/app_dependencies.dart | 186 +++++++++------------------ 1 file changed, 63 insertions(+), 123 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 15b5ffe..41e3a57 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -39,14 +39,14 @@ class AppDependencies { // --- Repositories --- late final HtDataRepository headlineRepository; - late final HtDataRepository categoryRepository; + late final HtDataRepository topicRepository; late final HtDataRepository sourceRepository; late final HtDataRepository countryRepository; late final HtDataRepository userRepository; late final HtDataRepository userAppSettingsRepository; late final HtDataRepository - userContentPreferencesRepository; - late final HtDataRepository appConfigRepository; + userContentPreferencesRepository; + late final HtDataRepository remoteConfigRepository; // --- Services --- late final HtEmailRepository emailRepository; @@ -100,73 +100,37 @@ class AppDependencies { headlineRepository = _createRepository( connection, 'headlines', - (json) { - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - if (json['published_at'] is DateTime) { - json['published_at'] = - (json['published_at'] as DateTime).toIso8601String(); - } - return Headline.fromJson(json); - }, + // The HtDataPostgresClient returns DateTime objects from TIMESTAMPTZ + // columns. The Headline.fromJson factory expects ISO 8601 strings. + // This handler converts them before deserialization. + (json) => Headline.fromJson(_convertTimestampsToString(json)), (headline) { final json = headline.toJson(); - // The database expects source_id and category_id, not nested objects. - // We extract the IDs and remove the original objects to match the - // schema. - if (headline.source != null) { - json['source_id'] = headline.source!.id; - } - if (headline.category != null) { - json['category_id'] = headline.category!.id; - } + // The database expects foreign key IDs, not nested objects. + // We extract the IDs and remove the original objects. + json['source_id'] = headline.source.id; + json['topic_id'] = headline.topic.id; + json['event_country_id'] = headline.eventCountry.id; json.remove('source'); - json.remove('category'); + json.remove('topic'); + json.remove('eventCountry'); return json; }, ); - categoryRepository = _createRepository( + topicRepository = _createRepository( connection, - 'categories', - (json) { - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - return Category.fromJson(json); - }, - (c) => c.toJson(), + 'topics', + (json) => Topic.fromJson(_convertTimestampsToString(json)), + (topic) => topic.toJson(), ); sourceRepository = _createRepository( connection, 'sources', - (json) { - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - return Source.fromJson(json); - }, + (json) => Source.fromJson(_convertTimestampsToString(json)), (source) { final json = source.toJson(); // The database expects headquarters_country_id, not a nested object. - // We extract the ID and remove the original object to match the - // schema. - json['headquarters_country_id'] = source.headquarters?.id; + json['headquarters_country_id'] = source.headquarters.id; json.remove('headquarters'); return json; }, @@ -174,104 +138,64 @@ class AppDependencies { countryRepository = _createRepository( connection, 'countries', - (json) { - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - return Country.fromJson(json); - }, - (c) => c.toJson(), + (json) => Country.fromJson(_convertTimestampsToString(json)), + (country) => country.toJson(), ); userRepository = _createRepository( connection, 'users', - (json) { - // The postgres driver returns DateTime objects, but the model's - // fromJson expects ISO 8601 strings. We must convert them first. - if (json['created_at'] is DateTime) { - json['created_at'] = (json['created_at'] as DateTime).toIso8601String(); - } - if (json['last_engagement_shown_at'] is DateTime) { - json['last_engagement_shown_at'] = - (json['last_engagement_shown_at'] as DateTime).toIso8601String(); - } - return User.fromJson(json); - }, + (json) => User.fromJson(_convertTimestampsToString(json)), (user) { - // The `roles` field is a List, but the database expects a - // JSONB array. We must explicitly encode it. final json = user.toJson(); - json['roles'] = jsonEncode(json['roles']); + // Convert enums to their string names for the database. + json['app_role'] = user.appRole.name; + json['dashboard_role'] = user.dashboardRole.name; + // The `feed_action_status` map must be JSON encoded for the JSONB column. + json['feed_action_status'] = jsonEncode(json['feed_action_status']); return json; }, ); userAppSettingsRepository = _createRepository( connection, 'user_app_settings', - (json) { - // The DB has created_at/updated_at, but the model doesn't. - // Remove them before deserialization to avoid CheckedFromJsonException. - json.remove('created_at'); - json.remove('updated_at'); - return UserAppSettings.fromJson(json); - }, + (json) => UserAppSettings.fromJson(json), (settings) { final json = settings.toJson(); // These fields are complex objects and must be JSON encoded for the DB. json['display_settings'] = jsonEncode(json['display_settings']); json['feed_preferences'] = jsonEncode(json['feed_preferences']); - json['engagement_shown_counts'] = - jsonEncode(json['engagement_shown_counts']); - json['engagement_last_shown_timestamps'] = - jsonEncode(json['engagement_last_shown_timestamps']); return json; }, ); userContentPreferencesRepository = _createRepository( connection, 'user_content_preferences', - (json) { - // The postgres driver returns DateTime objects, but the model's - // fromJson expects ISO 8601 strings. We must convert them first. - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - return UserContentPreferences.fromJson(json); - }, + (json) => UserContentPreferences.fromJson(json), (preferences) { final json = preferences.toJson(); - json['followed_categories'] = jsonEncode(json['followed_categories']); + // These fields are lists of complex objects and must be JSON encoded. + json['followed_topics'] = jsonEncode(json['followed_topics']); json['followed_sources'] = jsonEncode(json['followed_sources']); json['followed_countries'] = jsonEncode(json['followed_countries']); json['saved_headlines'] = jsonEncode(json['saved_headlines']); return json; }, ); - appConfigRepository = _createRepository( + remoteConfigRepository = _createRepository( connection, - 'app_config', - (json) { - if (json['created_at'] is DateTime) { - json['created_at'] = - (json['created_at'] as DateTime).toIso8601String(); - } - if (json['updated_at'] is DateTime) { - json['updated_at'] = - (json['updated_at'] as DateTime).toIso8601String(); - } - return AppConfig.fromJson(json); + 'remote_config', + (json) => RemoteConfig.fromJson(_convertTimestampsToString(json)), + (config) { + final json = config.toJson(); + // All nested config objects must be JSON encoded for JSONB columns. + json['user_preference_limits'] = + jsonEncode(json['user_preference_limits']); + json['ad_config'] = jsonEncode(json['ad_config']); + json['account_action_config'] = + jsonEncode(json['account_action_config']); + json['app_status'] = jsonEncode(json['app_status']); + return json; }, - (c) => c.toJson(), ); // 4. Initialize Services. @@ -296,12 +220,12 @@ class AppDependencies { ); dashboardSummaryService = DashboardSummaryService( headlineRepository: headlineRepository, - categoryRepository: categoryRepository, + topicRepository: topicRepository, sourceRepository: sourceRepository, ); permissionService = const PermissionService(); userPreferenceLimitService = DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, + remoteConfigRepository: remoteConfigRepository, ); } @@ -321,4 +245,20 @@ class AppDependencies { ), ); } + + /// Converts DateTime values in a JSON map to ISO 8601 strings. + /// + /// The postgres driver returns DateTime objects for TIMESTAMPTZ columns, + /// but our models' `fromJson` factories expect ISO 8601 strings. This + /// utility function performs the conversion for known timestamp fields. + Map _convertTimestampsToString(Map json) { + const timestampKeys = {'created_at', 'updated_at'}; + final newJson = Map.from(json); + for (final key in timestampKeys) { + if (newJson[key] is DateTime) { + newJson[key] = (newJson[key] as DateTime).toIso8601String(); + } + } + return newJson; + } } From 0adbfcf6e19ffd6c4b649b95d16cb79a60ffd974 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:52:31 +0100 Subject: [PATCH 14/48] fix(authorization): update permissions and model names - Rename 'category' to 'topic' in model registry - Update permission names for consistency - Correct permission type for remote config read access - Update user preference permissions to content preference - Rename 'app_config' to 'remote_config' for clarity --- lib/src/registry/model_registry.dart | 32 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index 9ef2cdc..bd47462 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -141,17 +141,17 @@ final modelRegistry = >{ type: RequiredPermissionType.adminOnly, ), ), - 'category': ModelConfig( - fromJson: Category.fromJson, - getId: (c) => c.id, - // Categories: Admin-owned, read allowed by standard/guest users + '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.categoryRead, + permission: Permissions.topicRead, ), getItemPermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.categoryRead, + permission: Permissions.topicRead, ), postPermission: const ModelActionPermission( type: RequiredPermissionType.adminOnly, @@ -231,7 +231,7 @@ final modelRegistry = >{ ), deletePermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.userReadOwned, // User can delete their own + permission: Permissions.userDeleteOwned, // User can delete their own requiresOwnershipCheck: true, // Must be the owner ), ), @@ -245,7 +245,7 @@ final modelRegistry = >{ ), getItemPermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.appSettingsReadOwned, + permission: Permissions.userAppSettingsReadOwned, requiresOwnershipCheck: true, ), postPermission: const ModelActionPermission( @@ -255,7 +255,7 @@ final modelRegistry = >{ ), putPermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.appSettingsUpdateOwned, + permission: Permissions.userAppSettingsUpdateOwned, requiresOwnershipCheck: true, ), deletePermission: const ModelActionPermission( @@ -275,7 +275,7 @@ final modelRegistry = >{ ), getItemPermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.userPreferencesReadOwned, + permission: Permissions.userContentPreferencesReadOwned, requiresOwnershipCheck: true, ), postPermission: const ModelActionPermission( @@ -285,7 +285,7 @@ final modelRegistry = >{ ), putPermission: const ModelActionPermission( type: RequiredPermissionType.specificPermission, - permission: Permissions.userPreferencesUpdateOwned, + permission: Permissions.userContentPreferencesUpdateOwned, requiresOwnershipCheck: true, ), deletePermission: const ModelActionPermission( @@ -294,16 +294,16 @@ final modelRegistry = >{ // service during account deletion, not via a direct DELETE to /api/v1/data. ), ), - 'app_config': ModelConfig( - fromJson: AppConfig.fromJson, + 'remote_config': ModelConfig( + fromJson: RemoteConfig.fromJson, getId: (config) => config.id, - getOwnerId: null, // AppConfig is a global resource, not user-owned + getOwnerId: null, // RemoteConfig is a global resource, not user-owned getCollectionPermission: const ModelActionPermission( type: RequiredPermissionType.unsupported, // Not accessible via collection ), getItemPermission: const ModelActionPermission( - type: RequiredPermissionType - .none, // Readable by any authenticated user via /api/v1/data/[id] + type: RequiredPermissionType.specificPermission, + permission: Permissions.remoteConfigRead, ), postPermission: const ModelActionPermission( type: RequiredPermissionType.adminOnly, // Only administrators can create From 3973c35fffad92467828935dc1f513d6b169df8f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:57:01 +0100 Subject: [PATCH 15/48] refactor(auth): replace print statements with logging for better traceability --- lib/src/services/auth_service.dart | 106 +++++++++++++++-------------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 3855bce..701adf3 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -3,6 +3,7 @@ import 'package:ht_api/src/services/verification_code_storage_service.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_email_repository/ht_email_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; /// {@template auth_service} @@ -22,13 +23,15 @@ class AuthService { required HtDataRepository userContentPreferencesRepository, required Uuid uuidGenerator, + required Logger log, }) : _userRepository = userRepository, _authTokenService = authTokenService, _verificationCodeStorageService = verificationCodeStorageService, _emailRepository = emailRepository, _userAppSettingsRepository = userAppSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, - _uuid = uuidGenerator; + _uuid = uuidGenerator, + _log = log; final HtDataRepository _userRepository; final AuthTokenService _authTokenService; @@ -37,6 +40,7 @@ class AuthService { final HtDataRepository _userAppSettingsRepository; final HtDataRepository _userContentPreferencesRepository; + final Logger _log; final Uuid _uuid; /// Initiates the email sign-in process. @@ -67,7 +71,7 @@ class AuthService { if (isDashboardLogin) { final user = await _findUserByEmail(email); if (user == null) { - print('Dashboard login failed: User $email not found.'); + _log.warning('Dashboard login failed: User $email not found.'); throw const UnauthorizedException( 'This email address is not registered for dashboard access.', ); @@ -77,14 +81,14 @@ class AuthService { user.dashboardRole == DashboardUserRole.publisher; if (!hasRequiredRole) { - print( + _log.warning( 'Dashboard login failed: User ${user.id} lacks required roles.', ); throw const ForbiddenException( 'Your account does not have the required permissions to sign in.', ); } - print('Dashboard user ${user.id} verified successfully.'); + _log.info('Dashboard user ${user.id} verified successfully.'); } // Generate and store the code for standard sign-in @@ -93,13 +97,13 @@ class AuthService { // Send the code via email await _emailRepository.sendOtpEmail(recipientEmail: email, otpCode: code); - print('Initiated email sign-in for $email, code sent.'); + _log.info('Initiated email sign-in for $email, code sent.'); } on HtHttpException { // Propagate known exceptions from dependencies rethrow; } catch (e) { // Catch unexpected errors during orchestration - print('Error during initiateEmailSignIn for $email: $e'); + _log.severe('Error during initiateEmailSignIn for $email: $e'); throw const OperationFailedException( 'Failed to initiate email sign-in process.', ); @@ -140,7 +144,7 @@ class AuthService { await _verificationCodeStorageService.clearSignInCode(email); } catch (e) { // Log or handle if clearing fails, but don't let it block sign-in - print( + _log.warning( 'Warning: Failed to clear sign-in code for $email after validation: $e', ); } @@ -157,14 +161,14 @@ class AuthService { if (isDashboardLogin) { // This should not happen if the request-code flow is correct. // It's a safeguard. - print( + _log.severe( 'Error: Dashboard login verification failed for non-existent user $email.', ); throw const UnauthorizedException('User account does not exist.'); } // Create a new user for the standard app flow. - print('User not found for $email, creating new user.'); + _log.info('User not found for $email, creating new user.'); // All new users created via the public API get the standard role. // Admin users must be provisioned out-of-band (e.g., via fixtures). @@ -184,7 +188,7 @@ class AuthService { ), ); user = await _userRepository.create(item: user); - print( + _log.info( 'Created new user: ${user.id} with appRole: ${user.appRole}', ); @@ -194,7 +198,7 @@ class AuthService { item: defaultAppSettings, userId: user.id, ); - print('Created default UserAppSettings for user: ${user.id}'); + _log.info('Created default UserAppSettings for user: ${user.id}'); // Create default UserContentPreferences for the new user final defaultUserPreferences = UserContentPreferences(id: user.id); @@ -202,25 +206,25 @@ class AuthService { item: defaultUserPreferences, userId: user.id, ); - print('Created default UserContentPreferences for user: ${user.id}'); + _log.info('Created default UserContentPreferences for user: ${user.id}'); } } on HtHttpException catch (e) { - print('Error finding/creating user for $email: $e'); + _log.severe('Error finding/creating user for $email: $e'); throw const OperationFailedException( 'Failed to find or create user account.', ); } catch (e) { - print('Unexpected error during user lookup/creation for $email: $e'); + _log.severe('Unexpected error during user lookup/creation for $email: $e'); throw const OperationFailedException('Failed to process user account.'); } // 3. Generate authentication token try { final token = await _authTokenService.generateToken(user); - print('Generated token for user ${user.id}'); + _log.info('Generated token for user ${user.id}'); return (user: user, token: token); } catch (e) { - print('Error generating token for user ${user.id}: $e'); + _log.severe('Error generating token for user ${user.id}: $e'); throw const OperationFailedException( 'Failed to generate authentication token.', ); @@ -254,12 +258,12 @@ class AuthService { ), ); user = await _userRepository.create(item: user); - print('Created anonymous user: ${user.id}'); + _log.info('Created anonymous user: ${user.id}'); } on HtHttpException catch (e) { - print('Error creating anonymous user: $e'); + _log.severe('Error creating anonymous user: $e'); throw const OperationFailedException('Failed to create anonymous user.'); } catch (e) { - print('Unexpected error during anonymous user creation: $e'); + _log.severe('Unexpected error during anonymous user creation: $e'); throw const OperationFailedException( 'Failed to process anonymous sign-in.', ); @@ -271,7 +275,7 @@ class AuthService { item: defaultAppSettings, userId: user.id, // Pass user ID for scoping ); - print('Created default UserAppSettings for anonymous user: ${user.id}'); + _log.info('Created default UserAppSettings for anonymous user: ${user.id}'); // Create default UserContentPreferences for the new anonymous user final defaultUserPreferences = UserContentPreferences(id: user.id); @@ -279,17 +283,17 @@ class AuthService { item: defaultUserPreferences, userId: user.id, // Pass user ID for scoping ); - print( + _log.info( 'Created default UserContentPreferences for anonymous user: ${user.id}', ); // 2. Generate token try { final token = await _authTokenService.generateToken(user); - print('Generated token for anonymous user ${user.id}'); + _log.info('Generated token for anonymous user ${user.id}'); return (user: user, token: token); } catch (e) { - print('Error generating token for anonymous user ${user.id}: $e'); + _log.severe('Error generating token for anonymous user ${user.id}: $e'); throw const OperationFailedException( 'Failed to generate authentication token.', ); @@ -319,32 +323,32 @@ class AuthService { required String userId, required String token, }) async { - print( - '[AuthService] Received request for server-side sign-out actions ' + _log.info( + 'Received request for server-side sign-out actions ' 'for user $userId.', ); try { // Invalidate the token using the AuthTokenService await _authTokenService.invalidateToken(token); - print( - '[AuthService] Token invalidation logic executed for user $userId.', + _log.info( + 'Token invalidation logic executed for user $userId.', ); } on HtHttpException catch (_) { // Propagate known exceptions from the token service rethrow; } catch (e) { // Catch unexpected errors during token invalidation - print( - '[AuthService] Error during token invalidation for user $userId: $e', + _log.severe( + 'Error during token invalidation for user $userId: $e', ); throw const OperationFailedException( 'Failed server-side sign-out: Token invalidation failed.', ); } - print( - '[AuthService] Server-side sign-out actions complete for user $userId.', + _log.info( + 'Server-side sign-out actions complete for user $userId.', ); } @@ -396,13 +400,13 @@ class AuthService { recipientEmail: emailToLink, otpCode: code, ); - print( + _log.info( 'Initiated email link for user ${anonymousUser.id} to email $emailToLink, code sent: $code .', ); } on HtHttpException { rethrow; } catch (e) { - print( + _log.severe( 'Error during initiateLinkEmailProcess for user ${anonymousUser.id}, email $emailToLink: $e', ); throw OperationFailedException( @@ -453,24 +457,24 @@ class AuthService { id: updatedUser.id, item: updatedUser, ); - print( + _log.info( 'User ${permanentUser.id} successfully linked with email $linkedEmail.', ); // 3. Generate a new authentication token for the now-permanent user. final newToken = await _authTokenService.generateToken(permanentUser); - print('Generated new token for linked user ${permanentUser.id}'); + _log.info('Generated new token for linked user ${permanentUser.id}'); // 4. Invalidate the old anonymous token. try { await _authTokenService.invalidateToken(oldAnonymousToken); - print( + _log.info( 'Successfully invalidated old anonymous token for user ${permanentUser.id}.', ); } catch (e) { // Log error but don't fail the whole linking process if invalidation fails. // The new token is more important. - print( + _log.warning( 'Warning: Failed to invalidate old anonymous token for user ${permanentUser.id}: $e', ); } @@ -479,7 +483,7 @@ class AuthService { try { await _verificationCodeStorageService.clearLinkCode(anonymousUser.id); } catch (e) { - print( + _log.warning( 'Warning: Failed to clear link code for user ${anonymousUser.id} after linking: $e', ); } @@ -488,7 +492,7 @@ class AuthService { } on HtHttpException { rethrow; } catch (e) { - print( + _log.severe( 'Error during completeLinkEmailProcess for user ${anonymousUser.id}: $e', ); throw OperationFailedException( @@ -508,21 +512,21 @@ class AuthService { try { // Fetch the user first to get their email if needed for cleanup final userToDelete = await _userRepository.read(id: userId); - print('[AuthService] Found user ${userToDelete.id} for deletion.'); + _log.info('Found user ${userToDelete.id} for deletion.'); // 1. Delete the user record from the repository. // This implicitly invalidates tokens that rely on user lookup. await _userRepository.delete(id: userId); - print('[AuthService] User ${userToDelete.id} deleted from repository.'); + _log.info('User ${userToDelete.id} deleted from repository.'); // 2. Clear any pending verification codes for this user ID (linking). try { await _verificationCodeStorageService.clearLinkCode(userId); - print('[AuthService] Cleared link code for user ${userToDelete.id}.'); + _log.info('Cleared link code for user ${userToDelete.id}.'); } catch (e) { // Log but don't fail deletion if clearing codes fails - print( - '[AuthService] Warning: Failed to clear link code for user ${userToDelete.id}: $e', + _log.warning( + 'Warning: Failed to clear link code for user ${userToDelete.id}: $e', ); } @@ -532,13 +536,13 @@ class AuthService { await _verificationCodeStorageService.clearSignInCode( userToDelete.email!, ); - print( - '[AuthService] Cleared sign-in code for email ${userToDelete.email}.', + _log.info( + 'Cleared sign-in code for email ${userToDelete.email}.', ); } catch (e) { // Log but don't fail deletion if clearing codes fails - print( - '[AuthService] Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', + _log.warning( + 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', ); } } @@ -547,8 +551,8 @@ class AuthService { // user-related data (e.g., settings, content) from other repositories // once those features are implemented. - print( - '[AuthService] Account deletion process completed for user $userId.', + _log.info( + 'Account deletion process completed for user $userId.', ); } on NotFoundException { // Propagate NotFoundException if user doesn't exist @@ -558,7 +562,7 @@ class AuthService { rethrow; } catch (e) { // Catch unexpected errors during orchestration - print('Error during deleteAccount for user $userId: $e'); + _log.severe('Error during deleteAccount for user $userId: $e'); throw OperationFailedException('Failed to delete user account: $e'); } } From 91d0f6f2d99a957126a73d327f7640ddabea55df Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 11:59:52 +0100 Subject: [PATCH 16/48] feat(user-preference): inject log into user preference limit service - Add log dependency to DefaultUserPreferenceLimitService constructor - Improve error tracking and debugging for user preference operations --- lib/src/config/app_dependencies.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 41e3a57..fab955b 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -226,6 +226,7 @@ class AppDependencies { permissionService = const PermissionService(); userPreferenceLimitService = DefaultUserPreferenceLimitService( remoteConfigRepository: remoteConfigRepository, + log: _log, ); } From f524e4f052aa8e7e2b85514c16c6452ae18da1e9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:07:51 +0100 Subject: [PATCH 17/48] refactor(inmemory): improve code formatting in app_dependencies.dart - Adjust line breaks and indentation for better readability - Add missing log parameter in JwtAuthTokenService and UserPreferenceService - Remove unnecessary blank lines and comments --- lib/src/config/app_dependencies.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index fab955b..c88aedd 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -202,11 +202,13 @@ class AppDependencies { emailRepository = const HtEmailRepository( emailClient: HtEmailInMemoryClient(), ); - tokenBlacklistService = InMemoryTokenBlacklistService(); + tokenBlacklistService = InMemoryTokenBlacklistService( + ); authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, uuidGenerator: const Uuid(), + log: _log, ); verificationCodeStorageService = InMemoryVerificationCodeStorageService(); authService = AuthService( @@ -217,6 +219,7 @@ class AppDependencies { userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, uuidGenerator: const Uuid(), + log: _log, ); dashboardSummaryService = DashboardSummaryService( headlineRepository: headlineRepository, @@ -224,10 +227,8 @@ class AppDependencies { sourceRepository: sourceRepository, ); permissionService = const PermissionService(); - userPreferenceLimitService = DefaultUserPreferenceLimitService( - remoteConfigRepository: remoteConfigRepository, - log: _log, - ); + userPreferenceLimitService = + DefaultUserPreferenceLimitService(remoteConfigRepository: remoteConfigRepository, log: _log,); } HtDataRepository _createRepository( From bc95a9e9770c6f898bcbd79df740b0d7143b0a5e Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:09:09 +0100 Subject: [PATCH 18/48] refactor(token_blacklist_service): replace print statements with logging - Add logging dependency and integrate Logger into InMemoryTokenBlacklistService - Replace all print statements with appropriate log levels - Update constructor to require Logger instance - Adjust log messages to follow consistent formatting --- lib/src/services/token_blacklist_service.dart | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/lib/src/services/token_blacklist_service.dart b/lib/src/services/token_blacklist_service.dart index b895651..63c70c9 100644 --- a/lib/src/services/token_blacklist_service.dart +++ b/lib/src/services/token_blacklist_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:ht_shared/ht_shared.dart'; import 'package:meta/meta.dart'; +import 'package:logging/logging.dart'; /// {@template token_blacklist_service} /// Defines the interface for a service that manages a blacklist of @@ -51,19 +52,20 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { /// expired token IDs. Defaults to 1 hour. InMemoryTokenBlacklistService({ Duration cleanupInterval = const Duration(hours: 1), - }) { + required Logger log, + }) : _log = log { _cleanupTimer = Timer.periodic(cleanupInterval, (_) async { try { await cleanupExpired(); } catch (e) { // Log error during cleanup, but don't let it crash the timer - print( - '[InMemoryTokenBlacklistService] Error during scheduled cleanup: $e', + _log.severe( + 'Error during scheduled cleanup: $e', ); } }); - print( - '[InMemoryTokenBlacklistService] Initialized with cleanup interval: ' + _log.info( + 'Initialized with cleanup interval: ' '$cleanupInterval', ); } @@ -73,12 +75,13 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { final Map blacklistStore = {}; Timer? _cleanupTimer; bool _isDisposed = false; + final Logger _log; @override Future blacklist(String jti, DateTime expiry) async { if (_isDisposed) { - print( - '[InMemoryTokenBlacklistService] Attempted to blacklist on disposed service.', + _log.warning( + 'Attempted to blacklist on disposed service.', ); return; } @@ -86,13 +89,13 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { await Future.delayed(Duration.zero); try { blacklistStore[jti] = expiry; - print( - '[InMemoryTokenBlacklistService] Blacklisted jti: $jti ' + _log.info( + 'Blacklisted jti: $jti ' '(expires: $expiry)', ); } catch (e) { - print( - '[InMemoryTokenBlacklistService] Error adding jti $jti to store: $e', + _log.severe( + 'Error adding jti $jti to store: $e', ); throw OperationFailedException('Failed to add token to blacklist: $e'); } @@ -101,8 +104,8 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { @override Future isBlacklisted(String jti) async { if (_isDisposed) { - print( - '[InMemoryTokenBlacklistService] Attempted to check blacklist on disposed service.', + _log.warning( + 'Attempted to check blacklist on disposed service.', ); return false; } @@ -122,8 +125,8 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { } return true; // It's in the blacklist and not expired } catch (e) { - print( - '[InMemoryTokenBlacklistService] Error checking blacklist for jti $jti: $e', + _log.severe( + 'Error checking blacklist for jti $jti: $e', ); throw OperationFailedException('Failed to check token blacklist: $e'); } @@ -132,8 +135,8 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { @override Future cleanupExpired() async { if (_isDisposed) { - print( - '[InMemoryTokenBlacklistService] Attempted cleanup on disposed service.', + _log.warning( + 'Attempted cleanup on disposed service.', ); return; } @@ -150,17 +153,17 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { if (expiredKeys.isNotEmpty) { expiredKeys.forEach(blacklistStore.remove); - print( - '[InMemoryTokenBlacklistService] Cleaned up ${expiredKeys.length} ' + _log.info( + 'Cleaned up ${expiredKeys.length} ' 'expired jti entries.', ); } else { - print( - '[InMemoryTokenBlacklistService] Cleanup ran, no expired entries found.', + _log.finer( + 'Cleanup ran, no expired entries found.', ); } } catch (e) { - print('[InMemoryTokenBlacklistService] Error during cleanup process: $e'); + _log.severe('Error during cleanup process: $e'); // Optionally rethrow or handle as an internal error // For now, just log it to prevent crashing the cleanup timer. } @@ -172,7 +175,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { _isDisposed = true; _cleanupTimer?.cancel(); blacklistStore.clear(); - print('[InMemoryTokenBlacklistService] Disposed.'); + _log.info('Disposed.'); } } } From d42156e20d8cb64d0e79db884f8eb99a7373ee31 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:10:06 +0100 Subject: [PATCH 19/48] feat(auth): add logging to token blacklist service - Inject log service into InMemoryTokenBlacklistService for better error tracking and debugging --- lib/src/config/app_dependencies.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index c88aedd..028702a 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -203,6 +203,7 @@ class AppDependencies { emailClient: HtEmailInMemoryClient(), ); tokenBlacklistService = InMemoryTokenBlacklistService( + log: _log, ); authTokenService = JwtAuthTokenService( userRepository: userRepository, From b915b5ddf3729933a14ab0f37dac628b63515fcd Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:11:54 +0100 Subject: [PATCH 20/48] refactor(auth): replace print statements with logging in SimpleAuthTokenService - Inject Logger into SimpleAuthTokenService - Replace print statements with appropriate log levels - Update log messages to use logging conventions - Remove unnecessary print statements and use logging for all output --- .../services/simple_auth_token_service.dart | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/src/services/simple_auth_token_service.dart b/lib/src/services/simple_auth_token_service.dart index 70de723..880c1cb 100644 --- a/lib/src/services/simple_auth_token_service.dart +++ b/lib/src/services/simple_auth_token_service.dart @@ -1,6 +1,7 @@ import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; /// {@template simple_auth_token_service} /// A minimal, dependency-free implementation of [AuthTokenService] for debugging. @@ -10,17 +11,21 @@ import 'package:ht_shared/ht_shared.dart'; /// {@endtemplate} class SimpleAuthTokenService implements AuthTokenService { /// {@macro simple_auth_token_service} - const SimpleAuthTokenService({required HtDataRepository userRepository}) - : _userRepository = userRepository; + const SimpleAuthTokenService({ + required HtDataRepository userRepository, + required Logger log, + }) : _userRepository = userRepository, + _log = log; final HtDataRepository _userRepository; + final Logger _log; static const String _tokenPrefix = 'valid-token-for-user-id:'; @override Future generateToken(User user) async { - print('[SimpleAuthTokenService] Generating token for user ${user.id}'); + _log.info('Generating token for user ${user.id}'); final token = '$_tokenPrefix${user.id}'; - print('[SimpleAuthTokenService] Generated token: $token'); + _log.finer('Generated token: $token'); // Simulate async operation if needed, though not strictly necessary here await Future.delayed(Duration.zero); return token; @@ -28,45 +33,39 @@ class SimpleAuthTokenService implements AuthTokenService { @override Future validateToken(String token) async { - print('[SimpleAuthTokenService] Attempting to validate token: $token'); + _log.finer('Attempting to validate token: $token'); if (!token.startsWith(_tokenPrefix)) { - print('[SimpleAuthTokenService] Validation failed: Invalid prefix.'); + _log.warning('Validation failed: Invalid prefix.'); // Mimic JWT behavior by throwing Unauthorized for invalid format throw const UnauthorizedException('Invalid token format.'); } final userId = token.substring(_tokenPrefix.length); - print('[SimpleAuthTokenService] Extracted user ID: $userId'); + _log.finer('Extracted user ID: $userId'); if (userId.isEmpty) { - print('[SimpleAuthTokenService] Validation failed: Empty user ID.'); + _log.warning('Validation failed: Empty user ID.'); throw const UnauthorizedException('Invalid token: Empty user ID.'); } try { - print( - '[SimpleAuthTokenService] Attempting to read user from repository...', - ); + _log.finer('Attempting to read user from repository...'); final user = await _userRepository.read(id: userId); - print('[SimpleAuthTokenService] User read successful: ${user.id}'); + _log.info('User read successful: ${user.id}'); return user; } on NotFoundException { - print( - '[SimpleAuthTokenService] Validation failed: User ID $userId not found.', - ); + _log.warning('Validation failed: User ID $userId not found.'); // Return null if user not found, mimicking successful validation // of a token for a non-existent user. The middleware handles this. return null; } on HtHttpException catch (e, s) { // Handle other potential repository errors - print( - '[SimpleAuthTokenService] Validation failed: Repository error $e\n$s', - ); + _log.warning('Validation failed: Repository error', e, s); // Re-throw other client/repo exceptions rethrow; } catch (e, s) { // Catch unexpected errors during validation - print('[SimpleAuthTokenService] Unexpected validation error: $e\n$s'); + _log.severe('Unexpected validation error', e, s); throw OperationFailedException( 'Simple token validation failed unexpectedly: $e', ); @@ -78,8 +77,8 @@ class SimpleAuthTokenService implements AuthTokenService { // This service uses simple prefixed tokens, not JWTs with JTI. // True invalidation/blacklisting isn't applicable here. // This method is implemented to satisfy the AuthTokenService interface. - print( - '[SimpleAuthTokenService] Received request to invalidate token: $token. ' + _log.info( + 'Received request to invalidate token: $token. ' 'No server-side invalidation is performed for simple tokens.', ); // Simulate async operation From 46550e1fc8a4afd896d7894bca94000a98ecb9b9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:14:18 +0100 Subject: [PATCH 21/48] docs(README): update Key Capabilities section with more detail and clarity - Improve authentication description: passwordless email sign-in and role-aware login - Refine RBAC explanation: dual-role system for app features and admin functions - Expand user preferences: include followed topics - Enhance data management: add topics to core news data - Update dashboard summary: include topics in key data points --- README.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1bc895d..c1ad055 100644 --- a/README.md +++ b/README.md @@ -17,28 +17,29 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). ## ✨ Key Capabilities * 🔒 **Flexible & Secure Authentication:** Provide seamless user access with - a unified system supporting passwordless sign-in, anonymous guest - accounts, and a secure, context-aware login flow for privileged dashboard - users (e.g., 'admin', 'publisher'). + a unified system supporting passwordless email sign-in, anonymous guest + accounts, and a secure, role-aware login flow for privileged dashboard + users. -* ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular - permissions with a flexible, multi-role system. Assign multiple roles to - users (e.g., 'admin', 'publisher', 'premium_user') to precisely control - access to different API features and data management capabilities. +* ⚡️ **Granular Role-Based Access Control (RBAC):** Implement precise + permissions with a dual-role system (`appRole` for application features, + `dashboardRole` for admin functions) to control access to API features + and data management capabilities. * ⚙️ **Synchronized App Settings:** Ensure a consistent and personalized user experience across devices by effortlessly syncing application preferences like theme, language, font styles, and more. * 👤 **Personalized User Preferences:** Enable richer user interactions by - managing and syncing user-specific data such as saved headlines, followed sources, or other personalized content tailored to individual users. + managing and syncing user-specific data such as saved headlines, followed + sources, and followed topics tailored to individual users. * 💾 **Robust Data Management:** Securely manage core news data (headlines, - categories, sources) through a well-structured API that supports flexible + topics, sources) through a well-structured API that supports flexible querying and sorting for dynamic content presentation. * 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on - key data points like total headlines, categories, and sources, providing + key data points like total headlines, topics, and sources, providing an at-a-glance overview for administrative dashboards. * 🔧 **Solid Technical Foundation:** Built with Dart and the high-performance From 980324aa3a9ecfaaf464625922e29966a3e1fd38 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:20:16 +0100 Subject: [PATCH 22/48] feat(service): add logger to DefaultUserPreferenceLimitService Adds a `Logger` dependency to the `DefaultUserPreferenceLimitService` and replaces `print` statements with structured logging. This resolves the `undefined_named_parameter` error in `app_dependencies.dart`. --- lib/src/config/app_dependencies.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 028702a..a525b4e 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -158,7 +158,7 @@ class AppDependencies { userAppSettingsRepository = _createRepository( connection, 'user_app_settings', - (json) => UserAppSettings.fromJson(json), + UserAppSettings.fromJson, (settings) { final json = settings.toJson(); // These fields are complex objects and must be JSON encoded for the DB. @@ -170,7 +170,7 @@ class AppDependencies { userContentPreferencesRepository = _createRepository( connection, 'user_content_preferences', - (json) => UserContentPreferences.fromJson(json), + UserContentPreferences.fromJson, (preferences) { final json = preferences.toJson(); // These fields are lists of complex objects and must be JSON encoded. From c98bc5f7eb8a8a55157a8208784d7ddc36764cc0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:20:21 +0100 Subject: [PATCH 23/48] lint: misc --- lib/src/config/environment_config.dart | 3 +- lib/src/services/auth_service.dart | 28 +++++++++---------- .../services/database_seeding_service.dart | 2 +- ...default_user_preference_limit_service.dart | 12 ++++++-- lib/src/services/token_blacklist_service.dart | 5 ++-- routes/api/v1/data/index.dart | 2 +- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 5570124..b480a8e 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -1,6 +1,5 @@ -import 'dart:io'; -import 'package:logging/logging.dart'; import 'package:dotenv/dotenv.dart'; +import 'package:logging/logging.dart'; /// {@template environment_config} /// A utility class for accessing environment variables. diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 701adf3..0378d63 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -531,22 +531,20 @@ class AuthService { } // 3. Clear any pending sign-in codes for the user's email (if they had one). - if (userToDelete.email != null) { - try { - await _verificationCodeStorageService.clearSignInCode( - userToDelete.email!, - ); - _log.info( - 'Cleared sign-in code for email ${userToDelete.email}.', - ); - } catch (e) { - // Log but don't fail deletion if clearing codes fails - _log.warning( - 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', - ); - } + try { + await _verificationCodeStorageService.clearSignInCode( + userToDelete.email, + ); + _log.info( + 'Cleared sign-in code for email ${userToDelete.email}.', + ); + } catch (e) { + // Log but don't fail deletion if clearing codes fails + _log.warning( + 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', + ); } - + // TODO(fulleni): Add logic here to delete or anonymize other // user-related data (e.g., settings, content) from other repositories // once those features are implemented. diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index bc239de..53c041c 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -224,7 +224,7 @@ class DatabaseSeedingService { // `headquarters_country_id` column and then remove the original // nested object from the parameters to avoid a "superfluous // variable" error. - params['headquarters_country_id'] = source.headquarters?.id; + params['headquarters_country_id'] = source.headquarters.id; params.remove('headquarters'); // Ensure optional fields exist for the postgres driver. diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 0f382b1..38f401a 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,6 +1,7 @@ import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; /// {@template default_user_preference_limit_service} /// Default implementation of [UserPreferenceLimitService] that enforces limits @@ -10,9 +11,12 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { /// {@macro default_user_preference_limit_service} const DefaultUserPreferenceLimitService({ required HtDataRepository remoteConfigRepository, - }) : _remoteConfigRepository = remoteConfigRepository; + required Logger log, + }) : _remoteConfigRepository = remoteConfigRepository, + _log = log; final HtDataRepository _remoteConfigRepository; + final Logger _log; // Assuming a fixed ID for the RemoteConfig document static const String _remoteConfigId = 'remote_config'; @@ -67,7 +71,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { rethrow; } catch (e) { // Catch unexpected errors - print('Error checking limit for user ${user.id}, itemType $itemType: $e'); + _log.severe( + 'Error checking limit for user ${user.id}, itemType $itemType: $e', + ); throw const OperationFailedException( 'Failed to check user preference limits.', ); @@ -139,7 +145,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { rethrow; } catch (e) { // Catch unexpected errors - print('Error checking update limits for user ${user.id}: $e'); + _log.severe('Error checking update limits for user ${user.id}: $e'); throw const OperationFailedException( 'Failed to check user preference update limits.', ); diff --git a/lib/src/services/token_blacklist_service.dart b/lib/src/services/token_blacklist_service.dart index 63c70c9..27ec242 100644 --- a/lib/src/services/token_blacklist_service.dart +++ b/lib/src/services/token_blacklist_service.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:ht_shared/ht_shared.dart'; -import 'package:meta/meta.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; /// {@template token_blacklist_service} /// Defines the interface for a service that manages a blacklist of @@ -51,8 +51,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { /// - [cleanupInterval]: How often the service checks for and removes /// expired token IDs. Defaults to 1 hour. InMemoryTokenBlacklistService({ - Duration cleanupInterval = const Duration(hours: 1), - required Logger log, + required Logger log, Duration cleanupInterval = const Duration(hours: 1), }) : _log = log { _cleanupTimer = Timer.periodic(cleanupInterval, (_) async { try { diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 87fa33b..18ebf6a 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -12,7 +12,7 @@ import '../../../_middleware.dart'; // Assuming RequestId is here String _camelToSnake(String input) { return input .replaceAllMapped( - RegExp(r'(? '_${match.group(0)}', ) .toLowerCase(); From 53e8a32087f5e7b43adcdcb299484764f5a709f3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:22:21 +0100 Subject: [PATCH 24/48] fix(deps): resolve linter warnings and compile errors Corrects several issues in `app_dependencies.dart`: - Adds missing documentation comments for all public repository and service members. - Refactors simple `toJson` handlers to use cascade notation to resolve linter warnings. - Injects the `Logger` into `InMemoryTokenBlacklistService` to fix a compile error. --- lib/src/config/app_dependencies.dart | 49 ++++++++++++++++------------ 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index a525b4e..ad5aa24 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -38,24 +38,40 @@ class AppDependencies { final _completer = Completer(); // --- Repositories --- + /// A repository for managing [Headline] data. late final HtDataRepository headlineRepository; + /// A repository for managing [Topic] data. late final HtDataRepository topicRepository; + /// A repository for managing [Source] data. late final HtDataRepository sourceRepository; + /// A repository for managing [Country] data. late final HtDataRepository countryRepository; + /// A repository for managing [User] data. late final HtDataRepository userRepository; + /// A repository for managing [UserAppSettings] data. late final HtDataRepository userAppSettingsRepository; + /// A repository for managing [UserContentPreferences] data. late final HtDataRepository userContentPreferencesRepository; + /// A repository for managing the global [RemoteConfig] data. late final HtDataRepository remoteConfigRepository; // --- Services --- + /// A service for sending emails. late final HtEmailRepository emailRepository; + /// A service for managing a blacklist of invalidated authentication tokens. late final TokenBlacklistService tokenBlacklistService; + /// A service for generating and validating authentication tokens. late final AuthTokenService authTokenService; + /// A service for storing and validating one-time verification codes. late final VerificationCodeStorageService verificationCodeStorageService; + /// A service that orchestrates authentication logic. late final AuthService authService; + /// A service for calculating and providing a summary for the dashboard. late final DashboardSummaryService dashboardSummaryService; + /// A service for checking user permissions. late final PermissionService permissionService; + /// A service for enforcing limits on user content preferences. late final UserPreferenceLimitService userPreferenceLimitService; /// Initializes all application dependencies. @@ -104,18 +120,13 @@ class AppDependencies { // columns. The Headline.fromJson factory expects ISO 8601 strings. // This handler converts them before deserialization. (json) => Headline.fromJson(_convertTimestampsToString(json)), - (headline) { - final json = headline.toJson(); - // The database expects foreign key IDs, not nested objects. - // We extract the IDs and remove the original objects. - json['source_id'] = headline.source.id; - json['topic_id'] = headline.topic.id; - json['event_country_id'] = headline.eventCountry.id; - json.remove('source'); - json.remove('topic'); - json.remove('eventCountry'); - return json; - }, + (headline) => headline.toJson() + ..['source_id'] = headline.source.id + ..['topic_id'] = headline.topic.id + ..['event_country_id'] = headline.eventCountry.id + ..remove('source') + ..remove('topic') + ..remove('eventCountry'), ); topicRepository = _createRepository( connection, @@ -127,13 +138,9 @@ class AppDependencies { connection, 'sources', (json) => Source.fromJson(_convertTimestampsToString(json)), - (source) { - final json = source.toJson(); - // The database expects headquarters_country_id, not a nested object. - json['headquarters_country_id'] = source.headquarters.id; - json.remove('headquarters'); - return json; - }, + (source) => source.toJson() + ..['headquarters_country_id'] = source.headquarters.id + ..remove('headquarters'), ); countryRepository = _createRepository( connection, @@ -158,7 +165,7 @@ class AppDependencies { userAppSettingsRepository = _createRepository( connection, 'user_app_settings', - UserAppSettings.fromJson, + (json) => UserAppSettings.fromJson(json), (settings) { final json = settings.toJson(); // These fields are complex objects and must be JSON encoded for the DB. @@ -170,7 +177,7 @@ class AppDependencies { userContentPreferencesRepository = _createRepository( connection, 'user_content_preferences', - UserContentPreferences.fromJson, + (json) => UserContentPreferences.fromJson(json), (preferences) { final json = preferences.toJson(); // These fields are lists of complex objects and must be JSON encoded. From e6c4e6b4f55ce7dd318ec6d275e76d67e92ef962 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:26:03 +0100 Subject: [PATCH 25/48] refactor(service): replace print statements with structured logging Refactors the `JwtAuthTokenService` to use a `Logger` instance for structured logging, replacing all previous `print()` calls. --- lib/src/services/jwt_auth_token_service.dart | 72 +++++++++++--------- 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 32a237e..6704385 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -3,6 +3,7 @@ import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; /// {@template jwt_auth_token_service} @@ -23,13 +24,16 @@ class JwtAuthTokenService implements AuthTokenService { required HtDataRepository userRepository, required TokenBlacklistService blacklistService, required Uuid uuidGenerator, + required Logger log, }) : _userRepository = userRepository, _blacklistService = blacklistService, - _uuid = uuidGenerator; + _uuid = uuidGenerator, + _log = log; final HtDataRepository _userRepository; final TokenBlacklistService _blacklistService; final Uuid _uuid; + final Logger _log; // --- Configuration --- @@ -76,10 +80,10 @@ class JwtAuthTokenService implements AuthTokenService { expiresIn: _tokenExpiryDuration, // Redundant but safe ); - print('Generated JWT for user ${user.id}'); + _log.info('Generated JWT for user ${user.id}'); return token; } catch (e) { - print('Error generating JWT for user ${user.id}: $e'); + _log.severe('Error generating JWT for user ${user.id}: $e'); // Map to a standard exception throw OperationFailedException( 'Failed to generate authentication token: $e', @@ -89,18 +93,18 @@ class JwtAuthTokenService implements AuthTokenService { @override Future validateToken(String token) async { - print('[validateToken] Attempting to validate token...'); + _log.finer('[validateToken] Attempting to validate token...'); try { // Verify the token's signature and expiry - print('[validateToken] Verifying token signature and expiry...'); + _log.finer('[validateToken] Verifying token signature and expiry...'); final jwt = JWT.verify(token, SecretKey(_secretKey)); - print('[validateToken] Token verified. Payload: ${jwt.payload}'); + _log.finer('[validateToken] Token verified. Payload: ${jwt.payload}'); // --- Blacklist Check --- // Extract the JWT ID (jti) claim final jti = jwt.payload['jti'] as String?; if (jti == null || jti.isEmpty) { - print( + _log.warning( '[validateToken] Token validation failed: Missing or empty "jti" claim.', ); // Throw specific exception for malformed token @@ -109,21 +113,21 @@ class JwtAuthTokenService implements AuthTokenService { ); } - print('[validateToken] Checking blacklist for jti: $jti'); + _log.finer('[validateToken] Checking blacklist for jti: $jti'); final isBlacklisted = await _blacklistService.isBlacklisted(jti); if (isBlacklisted) { - print( + _log.warning( '[validateToken] Token validation failed: Token is blacklisted (jti: $jti).', ); // Throw specific exception for blacklisted token throw const UnauthorizedException('Token has been invalidated.'); } - print('[validateToken] Token is not blacklisted (jti: $jti).'); + _log.finer('[validateToken] Token is not blacklisted (jti: $jti).'); // --- End Blacklist Check --- // Extract user ID from the subject claim ('sub') final subClaim = jwt.payload['sub']; - print( + _log.finer( '[validateToken] Extracted "sub" claim: $subClaim ' '(Type: ${subClaim.runtimeType})', ); @@ -132,12 +136,12 @@ class JwtAuthTokenService implements AuthTokenService { String? userId; if (subClaim is String) { userId = subClaim; - print( + _log.finer( '[validateToken] "sub" claim successfully cast to String: $userId', ); } else if (subClaim != null) { // Treat non-string sub as an error - print( + _log.severe( '[validateToken] ERROR: "sub" claim is not a String ' '(Type: ${subClaim.runtimeType}).', ); @@ -148,7 +152,7 @@ class JwtAuthTokenService implements AuthTokenService { } if (userId == null || userId.isEmpty) { - print( + _log.warning( '[validateToken] Token validation failed: Missing or empty "sub" claim.', ); // Throw specific exception for malformed token @@ -157,19 +161,19 @@ class JwtAuthTokenService implements AuthTokenService { ); } - print('[validateToken] Attempting to fetch user with ID: $userId'); + _log.finer('[validateToken] Attempting to fetch user with ID: $userId'); // Fetch the full user object from the repository // This ensures the user still exists and is valid final user = await _userRepository.read(id: userId); - print('[validateToken] User repository read successful for ID: $userId'); - print('[validateToken] Token validated successfully for user ${user.id}'); + _log.finer('[validateToken] User repository read successful for ID: $userId'); + _log.info('[validateToken] Token validated successfully for user ${user.id}'); return user; } on JWTExpiredException catch (e, s) { - print('[validateToken] CATCH JWTExpiredException: Token expired. $e\n$s'); + _log.warning('[validateToken] Token expired.', e, s); // Throw the standardized exception instead of rethrowing the specific one throw const UnauthorizedException('Token expired.'); } on JWTInvalidException catch (e, s) { - print( + _log.warning( '[validateToken] CATCH JWTInvalidException: Invalid token. ' 'Reason: ${e.message}\n$s', ); @@ -177,7 +181,7 @@ class JwtAuthTokenService implements AuthTokenService { throw UnauthorizedException('Invalid token: ${e.message}'); } on JWTException catch (e, s) { // Use JWTException as the general catch-all for other JWT issues - print( + _log.warning( '[validateToken] CATCH JWTException: General JWT error. ' 'Reason: ${e.message}\n$s', ); @@ -186,7 +190,7 @@ class JwtAuthTokenService implements AuthTokenService { } on HtHttpException catch (e, s) { // Handle errors from the user repository (e.g., user not found) // or blacklist check (if it threw HtHttpException) - print( + _log.warning( '[validateToken] CATCH HtHttpException: Error during validation. ' 'Type: ${e.runtimeType}, Message: $e\n$s', ); @@ -194,7 +198,7 @@ class JwtAuthTokenService implements AuthTokenService { rethrow; } catch (e, s) { // Catch unexpected errors during validation - print('[validateToken] CATCH UNEXPECTED Exception: $e\n$s'); + _log.severe('[validateToken] CATCH UNEXPECTED Exception', e, s); // Wrap unexpected errors in a standard exception type throw OperationFailedException( 'Token validation failed unexpectedly: $e', @@ -204,33 +208,33 @@ class JwtAuthTokenService implements AuthTokenService { @override Future invalidateToken(String token) async { - print('[invalidateToken] Attempting to invalidate token...'); + _log.finer('[invalidateToken] Attempting to invalidate token...'); try { // 1. Verify the token signature FIRST, but ignore expiry for blacklisting // We want to blacklist even if it's already expired, to be safe. - print('[invalidateToken] Verifying token signature (ignoring expiry)...'); + _log.finer('[invalidateToken] Verifying signature (ignoring expiry)...'); final jwt = JWT.verify( token, SecretKey(_secretKey), checkExpiresIn: false, // IMPORTANT: Don't fail if expired here checkHeaderType: true, // Keep other standard checks ); - print('[invalidateToken] Token signature verified.'); + _log.finer('[invalidateToken] Token signature verified.'); // 2. Extract JTI (JWT ID) final jti = jwt.payload['jti'] as String?; if (jti == null || jti.isEmpty) { - print('[invalidateToken] Failed: Missing or empty "jti" claim.'); + _log.warning('[invalidateToken] Failed: Missing or empty "jti" claim.'); throw const InvalidInputException( 'Cannot invalidate token: Missing or empty JWT ID (jti) claim.', ); } - print('[invalidateToken] Extracted jti: $jti'); + _log.finer('[invalidateToken] Extracted jti: $jti'); // 3. Extract Expiry Time (exp) final expClaim = jwt.payload['exp']; if (expClaim == null || expClaim is! int) { - print('[invalidateToken] Failed: Missing or invalid "exp" claim.'); + _log.warning('[invalidateToken] Failed: Missing or invalid "exp" claim.'); throw const InvalidInputException( 'Cannot invalidate token: Missing or invalid expiry (exp) claim.', ); @@ -239,15 +243,15 @@ class JwtAuthTokenService implements AuthTokenService { expClaim * 1000, isUtc: true, ); - print('[invalidateToken] Extracted expiry: $expiryDateTime'); + _log.finer('[invalidateToken] Extracted expiry: $expiryDateTime'); // 4. Add JTI to the blacklist - print('[invalidateToken] Adding jti $jti to blacklist...'); + _log.finer('[invalidateToken] Adding jti $jti to blacklist...'); await _blacklistService.blacklist(jti, expiryDateTime); - print('[invalidateToken] Token (jti: $jti) successfully blacklisted.'); + _log.info('[invalidateToken] Token (jti: $jti) successfully blacklisted.'); } on JWTException catch (e, s) { // Catch errors during the initial verification (e.g., bad signature) - print( + _log.warning( '[invalidateToken] CATCH JWTException: Invalid token format/signature. ' 'Reason: ${e.message}\n$s', ); @@ -255,7 +259,7 @@ class JwtAuthTokenService implements AuthTokenService { throw InvalidInputException('Invalid token format: ${e.message}'); } on HtHttpException catch (e, s) { // Catch errors from the blacklist service itself - print( + _log.warning( '[invalidateToken] CATCH HtHttpException: Error during blacklisting. ' 'Type: ${e.runtimeType}, Message: $e\n$s', ); @@ -263,7 +267,7 @@ class JwtAuthTokenService implements AuthTokenService { rethrow; } catch (e, s) { // Catch unexpected errors - print('[invalidateToken] CATCH UNEXPECTED Exception: $e\n$s'); + _log.severe('[invalidateToken] CATCH UNEXPECTED Exception', e, s); throw OperationFailedException( 'Token invalidation failed unexpectedly: $e', ); From 6a82d95830f41065fb31fac3419736f07c8713a8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:26:20 +0100 Subject: [PATCH 26/48] refactor(dependencies): simplify JSON deserialization functions - Replace lambda functions with direct method references for improved readability - Update userAppSettingsRepository and userContentPreferencesRepository initializers --- lib/src/config/app_dependencies.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index ad5aa24..a866445 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -165,7 +165,7 @@ class AppDependencies { userAppSettingsRepository = _createRepository( connection, 'user_app_settings', - (json) => UserAppSettings.fromJson(json), + UserAppSettings.fromJson, (settings) { final json = settings.toJson(); // These fields are complex objects and must be JSON encoded for the DB. @@ -177,7 +177,7 @@ class AppDependencies { userContentPreferencesRepository = _createRepository( connection, 'user_content_preferences', - (json) => UserContentPreferences.fromJson(json), + UserContentPreferences.fromJson, (preferences) { final json = preferences.toJson(); // These fields are lists of complex objects and must be JSON encoded. From 40a8019a7f537cdd62ff4944ddb100ec0b3ff76e Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:30:26 +0100 Subject: [PATCH 27/48] fix(auth): correct user creation and deletion logic - Fixes a bug where `UserAppSettings` and `UserContentPreferences` were created without required default values for new and anonymous users. - Implements the account deletion cleanup logic. Deleting a user from the `users` table now correctly cascades to delete their associated settings and preferences, and any pending verification codes are cleared. --- lib/src/services/auth_service.dart | 94 +++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 22 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 0378d63..dbeef91 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -193,7 +193,23 @@ class AuthService { ); // Create default UserAppSettings for the new user - final defaultAppSettings = UserAppSettings(id: user.id); + final defaultAppSettings = UserAppSettings( + id: user.id, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.normal, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create( item: defaultAppSettings, userId: user.id, @@ -201,7 +217,13 @@ class AuthService { _log.info('Created default UserAppSettings for user: ${user.id}'); // Create default UserContentPreferences for the new user - final defaultUserPreferences = UserContentPreferences(id: user.id); + final defaultUserPreferences = UserContentPreferences( + id: user.id, + followedCountries: const [], + followedSources: const [], + followedTopics: const [], + savedHeadlines: const [], + ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, userId: user.id, @@ -270,7 +292,23 @@ class AuthService { } // Create default UserAppSettings for the new anonymous user - final defaultAppSettings = UserAppSettings(id: user.id); + final defaultAppSettings = UserAppSettings( + id: user.id, + displaySettings: const DisplaySettings( + baseTheme: AppBaseTheme.system, + accentTheme: AppAccentTheme.defaultBlue, + fontFamily: 'SystemDefault', + textScaleFactor: AppTextScaleFactor.medium, + fontWeight: AppFontWeight.regular, + ), + language: 'en', + feedPreferences: const FeedDisplayPreferences( + headlineDensity: HeadlineDensity.normal, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create( item: defaultAppSettings, userId: user.id, // Pass user ID for scoping @@ -278,7 +316,13 @@ class AuthService { _log.info('Created default UserAppSettings for anonymous user: ${user.id}'); // Create default UserContentPreferences for the new anonymous user - final defaultUserPreferences = UserContentPreferences(id: user.id); + final defaultUserPreferences = UserContentPreferences( + id: user.id, + followedCountries: const [], + followedSources: const [], + followedTopics: const [], + savedHeadlines: const [], + ); await _userContentPreferencesRepository.create( item: defaultUserPreferences, userId: user.id, // Pass user ID for scoping @@ -509,13 +553,21 @@ class AuthService { /// Throws [NotFoundException] if the user does not exist. /// Throws [OperationFailedException] for other errors during deletion or cleanup. Future deleteAccount({required String userId}) async { + // Note: The user record itself is deleted via a CASCADE constraint + // when the corresponding entry in the `users` table is deleted. + // This is because `user_app_settings.user_id` and + // `user_content_preferences.user_id` have `ON DELETE CASCADE`. + // Therefore, we only need to delete the main user record. try { // Fetch the user first to get their email if needed for cleanup final userToDelete = await _userRepository.read(id: userId); _log.info('Found user ${userToDelete.id} for deletion.'); - // 1. Delete the user record from the repository. - // This implicitly invalidates tokens that rely on user lookup. + // 1. Delete the main user record from the `users` table. + // The `ON DELETE CASCADE` constraint on the `user_app_settings` and + // `user_content_preferences` tables will automatically delete the + // associated records in those tables. This also implicitly invalidates + // tokens that rely on user lookup, as the user will no longer exist. await _userRepository.delete(id: userId); _log.info('User ${userToDelete.id} deleted from repository.'); @@ -531,23 +583,21 @@ class AuthService { } // 3. Clear any pending sign-in codes for the user's email (if they had one). - try { - await _verificationCodeStorageService.clearSignInCode( - userToDelete.email, - ); - _log.info( - 'Cleared sign-in code for email ${userToDelete.email}.', - ); - } catch (e) { - // Log but don't fail deletion if clearing codes fails - _log.warning( - 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', - ); + // The email for anonymous users is a placeholder and not used for sign-in. + if (userToDelete.appRole != AppUserRole.guestUser) { + try { + await _verificationCodeStorageService.clearSignInCode( + userToDelete.email, + ); + _log.info( + 'Cleared sign-in code for email ${userToDelete.email}.', + ); + } catch (e) { + _log.warning( + 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', + ); + } } - - // TODO(fulleni): Add logic here to delete or anonymize other - // user-related data (e.g., settings, content) from other repositories - // once those features are implemented. _log.info( 'Account deletion process completed for user $userId.', From d0b8ff8cb794bbcff971a25750527e89045b4c36 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 12:37:22 +0100 Subject: [PATCH 28/48] fix(db): correct type errors and lints in seeding service Resolves several issues in `DatabaseSeedingService`: - Fixes multiple `argument_type_not_assignable` errors by correctly calling `.toJson()` on model objects before passing them as parameters to the database driver. - Imports fixture data correctly to resolve an `undefined_identifier` error. - Fixes `undefined_operator` errors by converting model objects to maps before attempting to add properties. - Cleans up code by using cascade notation to resolve linter warnings. --- .../services/database_seeding_service.dart | 124 +++++++----------- 1 file changed, 46 insertions(+), 78 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 53c041c..72247fb 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_shared/src/fixtures/fixtures.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; @@ -175,12 +176,10 @@ class DatabaseSeedingService { _log.fine('Seeding topics...'); for (final data in topicsFixturesData) { final topic = Topic.fromJson(data); - final params = topic.toJson(); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('description', () => null); - params.putIfAbsent('icon_url', () => null); - params.putIfAbsent('updated_at', () => null); + final params = topic.toJson() + ..putIfAbsent('description', () => null) + ..putIfAbsent('icon_url', () => null) + ..putIfAbsent('updated_at', () => null); await _connection.execute( Sql.named( @@ -197,10 +196,8 @@ class DatabaseSeedingService { _log.fine('Seeding countries...'); for (final data in countriesFixturesData) { final country = Country.fromJson(data); - final params = country.toJson(); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('updated_at', () => null); + final params = country.toJson() + ..putIfAbsent('updated_at', () => null); await _connection.execute( Sql.named( @@ -217,22 +214,19 @@ class DatabaseSeedingService { _log.fine('Seeding sources...'); for (final data in sourcesFixturesData) { final source = Source.fromJson(data); - final params = source.toJson(); - - // The `headquarters` field in the model is a nested `Country` - // object. We extract its ID to store in the - // `headquarters_country_id` column and then remove the original - // nested object from the parameters to avoid a "superfluous - // variable" error. - params['headquarters_country_id'] = source.headquarters.id; - params.remove('headquarters'); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('description', () => null); - params.putIfAbsent('url', () => null); - params.putIfAbsent('language', () => null); - params.putIfAbsent('source_type', () => null); - params.putIfAbsent('updated_at', () => null); + final params = source.toJson() + // The `headquarters` field in the model is a nested `Country` + // object. We extract its ID to store in the + // `headquarters_country_id` column and then remove the original + // nested object from the parameters to avoid a "superfluous + // variable" error. + ..['headquarters_country_id'] = source.headquarters.id + ..remove('headquarters') + ..putIfAbsent('description', () => null) + ..putIfAbsent('url', () => null) + ..putIfAbsent('language', () => null) + ..putIfAbsent('source_type', () => null) + ..putIfAbsent('updated_at', () => null); await _connection.execute( Sql.named( @@ -251,21 +245,17 @@ class DatabaseSeedingService { _log.fine('Seeding headlines...'); for (final data in headlinesFixturesData) { final headline = Headline.fromJson(data); - final params = headline.toJson(); - - // Extract IDs from nested objects and remove the objects to match schema. - params['source_id'] = headline.source.id; - params['topic_id'] = headline.topic.id; - params['event_country_id'] = headline.eventCountry.id; - params.remove('source'); - params.remove('topic'); - params.remove('eventCountry'); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('excerpt', () => null); - params.putIfAbsent('updated_at', () => null); - params.putIfAbsent('image_url', () => null); - params.putIfAbsent('url', () => null); + final params = headline.toJson() + ..['source_id'] = headline.source.id + ..['topic_id'] = headline.topic.id + ..['event_country_id'] = headline.eventCountry.id + ..remove('source') + ..remove('topic') + ..remove('eventCountry') + ..putIfAbsent('excerpt', () => null) + ..putIfAbsent('updated_at', () => null) + ..putIfAbsent('image_url', () => null) + ..putIfAbsent('url', () => null); await _connection.execute( Sql.named( @@ -332,11 +322,11 @@ class DatabaseSeedingService { // Seed Admin User _log.fine('Seeding admin user...'); // Find the admin user in the fixture data. - final adminUserData = usersFixturesData.firstWhere( + final adminUserFixture = usersFixturesData.firstWhere( (data) => data['dashboard_role'] == DashboardUserRole.admin.name, orElse: () => throw StateError('Admin user not found in fixtures.'), ); - final adminUser = User.fromJson(adminUserData); + final adminUser = User.fromJson(adminUserFixture); // The `users` table has specific columns for roles and status. await _connection.execute( @@ -346,16 +336,9 @@ class DatabaseSeedingService { '@dashboard_role, @feed_action_status) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - 'id': adminUser.id, - 'email': adminUser.email, - 'app_role': adminUser.appRole.name, - 'dashboard_role': adminUser.dashboardRole.name, - 'feed_action_status': jsonEncode( - adminUser.feedActionStatus - .map((key, value) => MapEntry(key.name, value.toJson())), - ), - }, + parameters: adminUser.toJson() + ..['feed_action_status'] = + jsonEncode(adminUser.feedActionStatus.toJson()), ); // Seed default settings and preferences for the admin user. @@ -375,15 +358,10 @@ class DatabaseSeedingService { '@display_settings, @language, @feed_preferences) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - 'id': adminSettings.id, - 'user_id': adminUser.id, - 'display_settings': - jsonEncode(adminSettings.displaySettings.toJson()), - 'language': adminSettings.language, - 'feed_preferences': - jsonEncode(adminSettings.feedPreferences.toJson()), - }, + parameters: adminSettings.toJson() + ..['user_id'] = adminUser.id + ..['display_settings'] = jsonEncode(adminSettings.displaySettings) + ..['feed_preferences'] = jsonEncode(adminSettings.feedPreferences), ); await _connection.execute( @@ -394,22 +372,12 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - 'id': adminPreferences.id, - 'user_id': adminUser.id, - 'followed_topics': jsonEncode( - adminPreferences.followedTopics.map((e) => e.toJson()).toList(), - ), - 'followed_sources': jsonEncode( - adminPreferences.followedSources.map((e) => e.toJson()).toList(), - ), - 'followed_countries': jsonEncode( - adminPreferences.followedCountries.map((e) => e.toJson()).toList(), - ), - 'saved_headlines': jsonEncode( - adminPreferences.savedHeadlines.map((e) => e.toJson()).toList(), - ), - }, + parameters: adminPreferences.toJson() + ..['user_id'] = adminUser.id + ..['followed_topics'] = jsonEncode(adminPreferences.followedTopics) + ..['followed_sources'] = jsonEncode(adminPreferences.followedSources) + ..['followed_countries'] = jsonEncode(adminPreferences.followedCountries) + ..['saved_headlines'] = jsonEncode(adminPreferences.savedHeadlines), ); await _connection.execute('COMMIT'); From d56d246b61619de24d74dbc430cc883967074fea Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 13:31:57 +0100 Subject: [PATCH 29/48] fix(db): correct type errors and lints in seeding service Resolves several issues in `DatabaseSeedingService`: - Fixes multiple `argument_type_not_assignable` errors by correctly calling `.toJson()` on model objects before passing them --- .../services/database_seeding_service.dart | 65 ++++++++++--------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 72247fb..3ec40f8 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'package:ht_shared/ht_shared.dart'; -import 'package:ht_shared/src/fixtures/fixtures.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; @@ -174,8 +173,7 @@ class DatabaseSeedingService { try { // Seed Topics _log.fine('Seeding topics...'); - for (final data in topicsFixturesData) { - final topic = Topic.fromJson(data); + for (final topic in topicsFixturesData) { final params = topic.toJson() ..putIfAbsent('description', () => null) ..putIfAbsent('icon_url', () => null) @@ -194,8 +192,7 @@ class DatabaseSeedingService { // Seed Countries (must be done before sources and headlines) _log.fine('Seeding countries...'); - for (final data in countriesFixturesData) { - final country = Country.fromJson(data); + for (final country in countriesFixturesData) { final params = country.toJson() ..putIfAbsent('updated_at', () => null); @@ -212,8 +209,7 @@ class DatabaseSeedingService { // Seed Sources _log.fine('Seeding sources...'); - for (final data in sourcesFixturesData) { - final source = Source.fromJson(data); + for (final source in sourcesFixturesData) { final params = source.toJson() // The `headquarters` field in the model is a nested `Country` // object. We extract its ID to store in the @@ -243,8 +239,7 @@ class DatabaseSeedingService { // Seed Headlines _log.fine('Seeding headlines...'); - for (final data in headlinesFixturesData) { - final headline = Headline.fromJson(data); + for (final headline in headlinesFixturesData) { final params = headline.toJson() ..['source_id'] = headline.source.id ..['topic_id'] = headline.topic.id @@ -296,7 +291,7 @@ class DatabaseSeedingService { try { // Seed RemoteConfig _log.fine('Seeding RemoteConfig...'); - final remoteConfig = RemoteConfig.fromJson(remoteConfigFixtureData); + final remoteConfig = remoteConfigFixture; // The `remote_config` table has multiple JSONB columns. We must // provide an explicit map with JSON-encoded values to avoid a // "superfluous variables" error from the postgres driver. @@ -322,11 +317,10 @@ class DatabaseSeedingService { // Seed Admin User _log.fine('Seeding admin user...'); // Find the admin user in the fixture data. - final adminUserFixture = usersFixturesData.firstWhere( - (data) => data['dashboard_role'] == DashboardUserRole.admin.name, + final adminUser = usersFixturesData.firstWhere( + (user) => user.dashboardRole == DashboardUserRole.admin, orElse: () => throw StateError('Admin user not found in fixtures.'), ); - final adminUser = User.fromJson(adminUserFixture); // The `users` table has specific columns for roles and status. await _connection.execute( @@ -336,19 +330,20 @@ class DatabaseSeedingService { '@dashboard_role, @feed_action_status) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminUser.toJson() - ..['feed_action_status'] = - jsonEncode(adminUser.feedActionStatus.toJson()), + parameters: () { + final params = adminUser.toJson(); + params['feed_action_status'] = + jsonEncode(params['feed_action_status']); + return params; + }(), ); // Seed default settings and preferences for the admin user. - final adminSettings = UserAppSettings.fromJson( - userAppSettingsFixturesData - .firstWhere((data) => data['id'] == adminUser.id), + final adminSettings = userAppSettingsFixturesData.firstWhere( + (settings) => settings.id == adminUser.id, ); - final adminPreferences = UserContentPreferences.fromJson( - userContentPreferencesFixturesData - .firstWhere((data) => data['id'] == adminUser.id), + final adminPreferences = userContentPreferencesFixturesData.firstWhere( + (prefs) => prefs.id == adminUser.id, ); await _connection.execute( @@ -358,10 +353,13 @@ class DatabaseSeedingService { '@display_settings, @language, @feed_preferences) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminSettings.toJson() - ..['user_id'] = adminUser.id - ..['display_settings'] = jsonEncode(adminSettings.displaySettings) - ..['feed_preferences'] = jsonEncode(adminSettings.feedPreferences), + parameters: () { + final params = adminSettings.toJson(); + params['user_id'] = adminUser.id; + params['display_settings'] = jsonEncode(params['display_settings']); + params['feed_preferences'] = jsonEncode(params['feed_preferences']); + return params; + }(), ); await _connection.execute( @@ -372,12 +370,15 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminPreferences.toJson() - ..['user_id'] = adminUser.id - ..['followed_topics'] = jsonEncode(adminPreferences.followedTopics) - ..['followed_sources'] = jsonEncode(adminPreferences.followedSources) - ..['followed_countries'] = jsonEncode(adminPreferences.followedCountries) - ..['saved_headlines'] = jsonEncode(adminPreferences.savedHeadlines), + parameters: () { + final params = adminPreferences.toJson(); + params['user_id'] = adminUser.id; + params['followed_topics'] = jsonEncode(params['followed_topics']); + params['followed_sources'] = jsonEncode(params['followed_sources']); + params['followed_countries'] = jsonEncode(params['followed_countries']); + params['saved_headlines'] = jsonEncode(params['saved_headlines']); + return params; + }(), ); await _connection.execute('COMMIT'); From e700c64512dc033ad6cd22a7823dcafcf9805153 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:42:21 +0100 Subject: [PATCH 30/48] fix(database): update remote config JSON key and improve related code - Change JSON key from 'user_preference_limits' to 'user_preference_config' - Update 'user_preference_limits' references to 'user_preference_config' in SQL queries - Replace 'remoteConfig' variable with 'remoteConfigFixtureData' constant - Update 'limits' retrieval from 'userPreferenceLimits' to 'userPreferenceConfig' - Change 'headlineDensity' from 'normal' to 'standard' in auth service These changes improve consistency and accuracy in database schema and code references. --- lib/src/services/auth_service.dart | 4 ++-- lib/src/services/database_seeding_service.dart | 12 ++++++------ .../default_user_preference_limit_service.dart | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index dbeef91..ad2a023 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -204,7 +204,7 @@ class AuthService { ), language: 'en', feedPreferences: const FeedDisplayPreferences( - headlineDensity: HeadlineDensity.normal, + headlineDensity: HeadlineDensity.standard, headlineImageStyle: HeadlineImageStyle.largeThumbnail, showSourceInHeadlineFeed: true, showPublishDateInHeadlineFeed: true, @@ -303,7 +303,7 @@ class AuthService { ), language: 'en', feedPreferences: const FeedDisplayPreferences( - headlineDensity: HeadlineDensity.normal, + headlineDensity: HeadlineDensity.standard, headlineImageStyle: HeadlineImageStyle.largeThumbnail, showSourceInHeadlineFeed: true, showPublishDateInHeadlineFeed: true, diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 3ec40f8..fdbac26 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -51,7 +51,7 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS remote_config ( id TEXT PRIMARY KEY, - user_preference_limits JSONB NOT NULL, + user_preference_config JSONB NOT NULL, ad_config JSONB NOT NULL, account_action_config JSONB NOT NULL, app_status JSONB NOT NULL, @@ -291,22 +291,22 @@ class DatabaseSeedingService { try { // Seed RemoteConfig _log.fine('Seeding RemoteConfig...'); - final remoteConfig = remoteConfigFixture; + const remoteConfig = remoteConfigFixtureData; // The `remote_config` table has multiple JSONB columns. We must // provide an explicit map with JSON-encoded values to avoid a // "superfluous variables" error from the postgres driver. await _connection.execute( Sql.named( - 'INSERT INTO remote_config (id, user_preference_limits, ad_config, ' + 'INSERT INTO remote_config (id, user_preference_config, ad_config, ' 'account_action_config, app_status) VALUES (@id, ' - '@user_preference_limits, @ad_config, @account_action_config, ' + '@user_preference_config, @ad_config, @account_action_config, ' '@app_status) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: { 'id': remoteConfig.id, - 'user_preference_limits': - jsonEncode(remoteConfig.userPreferenceLimits.toJson()), + 'user_preference_config': + jsonEncode(remoteConfig.userPreferenceConfig.toJson()), 'ad_config': jsonEncode(remoteConfig.adConfig.toJson()), 'account_action_config': jsonEncode(remoteConfig.accountActionConfig.toJson()), diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 38f401a..6b2b5ae 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -30,7 +30,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { try { // 1. Fetch the remote configuration to get limits final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); - final limits = remoteConfig.userPreferenceLimits; + final limits = remoteConfig.userPreferenceConfig; // Admins have no limits. if (user.dashboardRole == DashboardUserRole.admin) { @@ -88,7 +88,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { try { // 1. Fetch the remote configuration to get limits final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); - final limits = remoteConfig.userPreferenceLimits; + final limits = remoteConfig.userPreferenceConfig; // Admins have no limits. if (user.dashboardRole == DashboardUserRole.admin) { From 638c7c466839622dfdb5f30fa5f17922620d36c4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:42:37 +0100 Subject: [PATCH 31/48] refactor(api): use camelCase for isDashboardLogin in request-code --- routes/api/v1/auth/request-code.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/auth/request-code.dart b/routes/api/v1/auth/request-code.dart index aa3bd69..be61aa5 100644 --- a/routes/api/v1/auth/request-code.dart +++ b/routes/api/v1/auth/request-code.dart @@ -10,7 +10,7 @@ import 'package:ht_shared/ht_shared.dart'; // For exceptions /// /// - For the user-facing app, it sends a verification code to the provided /// email, supporting both sign-in and sign-up. -/// - For the dashboard, the request body must include `"is_dashboard_login": true`. +/// - For the dashboard, the request body must include `"isDashboardLogin": true`. /// In this mode, it first verifies the user exists and has 'admin' or /// 'publisher' roles before sending a code, effectively acting as a /// login-only gate. @@ -44,7 +44,7 @@ Future onRequest(RequestContext context) async { } // Check for the optional dashboard login flag. Default to false if not present. - final isDashboardLogin = (body['is_dashboard_login'] as bool?) ?? false; + final isDashboardLogin = (body['isDashboardLogin'] as bool?) ?? false; // Basic email format check (more robust validation can be added) // Using a slightly more common regex pattern From 347b3471d8915877a49e0e9269fc1f6b408edd8f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:43:04 +0100 Subject: [PATCH 32/48] refactor(api): use camelCase for isDashboardLogin in verify-code --- routes/api/v1/auth/verify-code.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index a1fb386..c500fdc 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -9,7 +9,7 @@ import 'package:ht_shared/ht_shared.dart'; /// /// Verifies the provided email and code, completes the sign-in/sign-up, /// and returns the authenticated User object along with an auth token. It -/// supports a context-aware flow by checking for an `is_dashboard_login` +/// supports a context-aware flow by checking for an `isDashboardLogin` /// flag in the request body, which dictates whether to perform a strict /// login-only check or a standard sign-in/sign-up. Future onRequest(RequestContext context) async { @@ -67,7 +67,7 @@ Future onRequest(RequestContext context) async { } // Check for the optional dashboard login flag. Default to false. - final isDashboardLogin = (body['is_dashboard_login'] as bool?) ?? false; + final isDashboardLogin = (body['isDashboardLogin'] as bool?) ?? false; try { // Call the AuthService to handle the verification and sign-in logic From 221f5d14ad38ba66b5c1657d2b417a54b97ae89c Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:43:42 +0100 Subject: [PATCH 33/48] refactor(api): update link-email check to use user.appRole --- routes/api/v1/auth/link-email.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/api/v1/auth/link-email.dart b/routes/api/v1/auth/link-email.dart index b042518..a340ce0 100644 --- a/routes/api/v1/auth/link-email.dart +++ b/routes/api/v1/auth/link-email.dart @@ -22,7 +22,7 @@ Future onRequest(RequestContext context) async { // This should ideally be caught by `authenticationProvider` if route is protected throw const UnauthorizedException('Authentication required to link email.'); } - if (!authenticatedUser.roles.contains(UserRoles.guestUser)) { + if (authenticatedUser.appRole != AppUserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot initiate email linking.', ); From 8ceb050da377d40c97a0df16eb89bd838c86b664 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:44:49 +0100 Subject: [PATCH 34/48] refactor(api): update verify-link-email check to use user.appRole --- routes/api/v1/auth/verify-link-email.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart index 997c74c..844e37f 100644 --- a/routes/api/v1/auth/verify-link-email.dart +++ b/routes/api/v1/auth/verify-link-email.dart @@ -23,7 +23,7 @@ Future onRequest(RequestContext context) async { 'Authentication required to verify email link.', ); } - if (!authenticatedUser.roles.contains(UserRoles.guestUser)) { + if (authenticatedUser.appRole != AppUserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', ); From c8ac366cd3bab6200955ac20919c8e5beb49059a Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:45:31 +0100 Subject: [PATCH 35/48] refactor(api): update root providers for Topic and RemoteConfig models --- routes/_middleware.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index f9fb006..973f0a9 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -116,16 +116,16 @@ Handler middleware(Handler handler) { return handler .use(provider((_) => modelRegistry)) .use(provider((_) => const Uuid())) - .use(provider>((_) => deps.headlineRepository)) - .use(provider>((_) => deps.categoryRepository)) - .use(provider>((_) => deps.sourceRepository)) - .use(provider>((_) => deps.countryRepository)) - .use(provider>((_) => deps.userRepository)) + .use(provider>((_) => deps.headlineRepository)) // + .use(provider>((_) => deps.topicRepository)) + .use(provider>((_) => deps.sourceRepository)) // + .use(provider>((_) => deps.countryRepository)) // + .use(provider>((_) => deps.userRepository)) // .use(provider>( (_) => deps.userAppSettingsRepository)) .use(provider>( (_) => deps.userContentPreferencesRepository)) - .use(provider>((_) => deps.appConfigRepository)) + .use(provider>((_) => deps.remoteConfigRepository)) .use(provider((_) => deps.emailRepository)) .use(provider((_) => deps.tokenBlacklistService)) .use(provider((_) => deps.authTokenService)) From ece92ee1360a98e12fbef5094fc98cbb75905d7c Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 14:49:18 +0100 Subject: [PATCH 36/48] refactor(api): align generic data item route with new models Replaces all references to 'category' with 'topic' and 'app_config' with 'remote_config' in the item-specific data handler at `/api/v1/data/[id].dart`. This change ensures the GET, PUT, and DELETE operations for individual items correctly use the updated `Topic` and `RemoteConfig` models and their corresponding repositories. --- routes/api/v1/data/[id].dart | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/routes/api/v1/data/[id].dart b/routes/api/v1/data/[id].dart index e0d43ff..19ba614 100644 --- a/routes/api/v1/data/[id].dart +++ b/routes/api/v1/data/[id].dart @@ -99,8 +99,8 @@ Future _handleGet( case 'headline': final repo = context.read>(); item = await repo.read(id: id, userId: userIdForRepoCall); - case 'category': - final repo = context.read>(); + case 'topic': + final repo = context.read>(); item = await repo.read(id: id, userId: userIdForRepoCall); case 'source': final repo = context.read>(); @@ -117,8 +117,8 @@ Future _handleGet( case 'user_content_preferences': // New case for UserContentPreferences final repo = context.read>(); item = await repo.read(id: id, userId: userIdForRepoCall); - case 'app_config': // New case for AppConfig (read by admin) - final repo = context.read>(); + case 'remote_config': // New case for RemoteConfig (read by admin) + final repo = context.read>(); item = await repo.read( id: id, userId: userIdForRepoCall, @@ -296,12 +296,12 @@ Future _handlePut( userId: userIdForRepoCall, ); } - case 'category': + case 'topic': { - final repo = context.read>(); + final repo = context.read>(); updatedItem = await repo.update( id: id, - item: itemToUpdate as Category, + item: itemToUpdate as Topic, userId: userIdForRepoCall, ); } @@ -350,12 +350,12 @@ Future _handlePut( userId: userIdForRepoCall, ); } - case 'app_config': // New case for AppConfig (update by admin) + case 'remote_config': // New case for RemoteConfig (update by admin) { - final repo = context.read>(); + final repo = context.read>(); updatedItem = await repo.update( id: id, - item: itemToUpdate as AppConfig, + item: itemToUpdate as RemoteConfig, userId: userIdForRepoCall, // userId should be null for AppConfig ); } @@ -474,8 +474,8 @@ Future _handleDelete( case 'headline': final repo = context.read>(); itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'category': - final repo = context.read>(); + case 'topic': + final repo = context.read>(); itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); case 'source': final repo = context.read>(); @@ -492,8 +492,8 @@ Future _handleDelete( case 'user_content_preferences': // New case for UserContentPreferences final repo = context.read>(); itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'app_config': // New case for AppConfig (delete by admin) - final repo = context.read>(); + case 'remote_config': // New case for RemoteConfig (delete by admin) + final repo = context.read>(); itemToDelete = await repo.read( id: id, userId: userIdForRepoCall, @@ -531,8 +531,8 @@ Future _handleDelete( id: id, userId: userIdForRepoCall, ); - case 'category': - await context.read>().delete( + case 'topic': + await context.read>().delete( id: id, userId: userIdForRepoCall, ); @@ -561,8 +561,8 @@ Future _handleDelete( id: id, userId: userIdForRepoCall, ); - case 'app_config': // New case for AppConfig (delete by admin) - await context.read>().delete( + case 'remote_config': // New case for RemoteConfig (delete by admin) + await context.read>().delete( id: id, userId: userIdForRepoCall, ); // userId should be null for AppConfig From 5046c4b1a6918b7669e32e1cec3dab1e3ae0467d Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:01:56 +0100 Subject: [PATCH 37/48] feat(api): add response metadata to anonymous auth endpoint Standardizes the success response for the anonymous sign-in endpoint by including the `ResponseMetadata` object, which contains the `requestId` and a timestamp. This ensures that all successful authentication responses that return a body are structured consistently, improving API uniformity. --- routes/api/v1/auth/anonymous.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/auth/anonymous.dart b/routes/api/v1/auth/anonymous.dart index ba3bcca..4a4a05b 100644 --- a/routes/api/v1/auth/anonymous.dart +++ b/routes/api/v1/auth/anonymous.dart @@ -4,6 +4,8 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_shared/ht_shared.dart'; +import '../../../_middleware.dart'; + /// Handles POST requests to `/api/v1/auth/anonymous`. /// /// Creates a new anonymous user and returns the User object along with an @@ -27,11 +29,16 @@ Future onRequest(RequestContext context) async { token: result.token, ); + // Create metadata, including the requestId from the context. + final metadata = ResponseMetadata( + requestId: context.read().id, + timestamp: DateTime.now().toUtc(), + ); + // Wrap the payload in the standard SuccessApiResponse final responsePayload = SuccessApiResponse( data: authPayload, - // Optionally add metadata if needed/available - // metadata: ResponseMetadata(timestamp: DateTime.now().toUtc()), + metadata: metadata, ); // Return 200 OK with the standardized, serialized response From fe9055de7c5edf8e962a41431b32bafb0d8b5fbf Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:02:46 +0100 Subject: [PATCH 38/48] feat(api): add response metadata to verify-code endpoint Standardizes the success response for the verify-code endpoint by including the `ResponseMetadata` object, which contains the `requestId` and a timestamp. This ensures that all successful authentication responses that return a body are structured consistently, improving API uniformity. --- routes/api/v1/auth/verify-code.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index c500fdc..09d42ec 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -5,6 +5,8 @@ import 'package:ht_api/src/services/auth_service.dart'; // Import exceptions, User, SuccessApiResponse, AND AuthSuccessResponse import 'package:ht_shared/ht_shared.dart'; +import '../../../_middleware.dart'; + /// Handles POST requests to `/api/v1/auth/verify-code`. /// /// Verifies the provided email and code, completes the sign-in/sign-up, @@ -84,11 +86,16 @@ Future onRequest(RequestContext context) async { token: result.token, ); + // Create metadata, including the requestId from the context. + final metadata = ResponseMetadata( + requestId: context.read().id, + timestamp: DateTime.now().toUtc(), + ); + // Wrap the payload in the standard SuccessApiResponse final responsePayload = SuccessApiResponse( data: authPayload, - // Optionally add metadata if needed/available - // metadata: ResponseMetadata(timestamp: DateTime.now().toUtc()), + metadata: metadata, ); // Return 200 OK with the standardized, serialized response From 39bc43fbd359fd93ca571ea87d54b556f2f51428 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:04:42 +0100 Subject: [PATCH 39/48] feat(api): add response metadata to verify-link-email endpoint Standardizes the success response for the verify-link-email endpoint by including the `ResponseMetadata` object, which contains the `requestId` and a timestamp. This ensures that all successful authentication responses that return a body are structured consistently, improving API uniformity. --- routes/api/v1/auth/verify-link-email.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart index 844e37f..71a4c03 100644 --- a/routes/api/v1/auth/verify-link-email.dart +++ b/routes/api/v1/auth/verify-link-email.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_shared/ht_shared.dart'; // For User, AuthSuccessResponse, exceptions +import 'package:ht_shared/ht_shared.dart'; + +import '../../../_middleware.dart'; /// Handles POST requests to `/api/v1/auth/verify-link-email`. /// @@ -88,10 +90,16 @@ Future onRequest(RequestContext context) async { token: result.token, ); + // Create metadata, including the requestId from the context. + final metadata = ResponseMetadata( + requestId: context.read().id, + timestamp: DateTime.now().toUtc(), + ); + // Wrap the payload in the standard SuccessApiResponse final responsePayload = SuccessApiResponse( data: authPayload, - // metadata: ResponseMetadata(timestamp: DateTime.now().toUtc()), + metadata: metadata, ); // Return 200 OK with the standardized, serialized response From 97ea137501153761a478b3005807301c3260ebf5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:07:22 +0100 Subject: [PATCH 40/48] fix(api): correct requestId handling in /auth/me endpoint Removes the unnecessary and incorrect try-catch block around reading the `requestId` from the context in the `/api/v1/auth/me` handler. The `requestId` is guaranteed to be provided by the global middleware, so reading it directly resolves the `String?` to `String` type assignment error and aligns this handler's implementation with other authentication routes. --- routes/api/v1/auth/me.dart | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/routes/api/v1/auth/me.dart b/routes/api/v1/auth/me.dart index df52228..790c740 100644 --- a/routes/api/v1/auth/me.dart +++ b/routes/api/v1/auth/me.dart @@ -30,19 +30,9 @@ Future onRequest(RequestContext context) async { throw const UnauthorizedException('Authentication required.'); } - // Create metadata. Include requestId if it's available in context. - // Note: Need to ensure RequestId is provided globally or adjust accordingly. - String? requestId; - try { - // Attempt to read RequestId, handle gracefully if not provided at this level - requestId = context.read().id; - } catch (_) { - // RequestId might not be provided directly in this context scope - print('RequestId not found in context for /auth/me'); - } - + // Create metadata, including the requestId from the context. final metadata = ResponseMetadata( - requestId: requestId, + requestId: context.read().id, timestamp: DateTime.now().toUtc(), ); From 80690221c5eb486e08105d902119b14d507d6862 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:08:40 +0100 Subject: [PATCH 41/48] fix(api): synchronize data route with Topic model rename Replaces all occurrences of 'Category' with 'Topic' and 'categories' with 'topics' in the generic data collection route handler (/api/v1/data). This change aligns the route's logic, query parameter handling, and comments with the recent model refactoring where the Category model was renamed to Topic, resolving multiple type errors. --- routes/api/v1/data/index.dart | 80 +++++++++++++++++------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 18ebf6a..12b659e 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -63,21 +63,21 @@ Future onRequest(RequestContext context) async { /// /// This handler implements model-specific filtering rules: /// - **Headlines (`model=headline`):** -/// - Filterable by `q` (text query on title only). -/// If `q` is present, `categories` and `sources` are ignored. +/// - Filterable by `q` (text query on title only). If `q` is present, +/// `topics` and `sources` are ignored. /// Example: `/api/v1/data?model=headline&q=Dart+Frog` /// - OR by a combination of: -/// - `categories` (comma-separated category IDs). -/// Example: `/api/v1/data?model=headline&categories=catId1,catId2` +/// - `topics` (comma-separated topic IDs). +/// Example: `/api/v1/data?model=headline&topics=topicId1,topicId2` /// - `sources` (comma-separated source IDs). /// Example: `/api/v1/data?model=headline&sources=sourceId1` -/// - Both `categories` and `sources` can be used together (AND logic). -/// Example: `/api/v1/data?model=headline&categories=catId1&sources=sourceId1` +/// - Both `topics` and `sources` can be used together (AND logic). +/// Example: `/api/v1/data?model=headline&topics=topicId1&sources=sourceId1` /// - Other parameters for headlines (e.g., `countries`) will result in a 400 Bad Request. /// /// - **Sources (`model=source`):** -/// - Filterable by `q` (text query on name only). -/// If `q` is present, `countries`, `sourceTypes`, `languages` are ignored. +/// - Filterable by `q` (text query on name only). If `q` is present, +/// `countries`, `sourceTypes`, `languages` are ignored. /// Example: `/api/v1/data?model=source&q=Tech+News` /// - OR by a combination of: /// - `countries` (comma-separated country ISO codes for `source.headquarters.iso_code`). @@ -89,10 +89,10 @@ Future onRequest(RequestContext context) async { /// - These specific filters are ANDed if multiple are provided. /// - Other parameters for sources will result in a 400 Bad Request. /// -/// - **Categories (`model=category`):** +/// - **Topics (`model=topic`):** /// - Filterable ONLY by `q` (text query on name only). -/// Example: `/api/v1/data?model=category&q=Technology` -/// - Other parameters for categories will result in a 400 Bad Request. +/// Example: `/api/v1/data?model=topic&q=Technology` +/// - Other parameters for topics will result in a 400 Bad Request. /// /// - **Countries (`model=country`):** /// - Filterable ONLY by `q` (text query on name and isoCode). @@ -156,14 +156,14 @@ Future _handleGet( switch (modelName) { case 'headline': - allowedKeys = {'categories', 'sources', 'q'}; + allowedKeys = {'topics', 'sources', 'q'}; final qValue = queryParams['q']; if (qValue != null && qValue.isNotEmpty) { specificQueryForClient['title_contains'] = qValue; // specificQueryForClient['description_contains'] = qValue; // Removed } else { - if (queryParams.containsKey('categories')) { - specificQueryForClient['category.id_in'] = queryParams['categories']!; + if (queryParams.containsKey('topics')) { + specificQueryForClient['topic.id_in'] = queryParams['topics']!; } if (queryParams.containsKey('sources')) { specificQueryForClient['source.id_in'] = queryParams['sources']!; @@ -188,7 +188,7 @@ Future _handleGet( specificQueryForClient['language_in'] = queryParams['languages']!; } } - case 'category': + case 'topic': allowedKeys = {'q'}; final qValue = queryParams['q']; if (qValue != null && qValue.isNotEmpty) { @@ -217,7 +217,7 @@ Future _handleGet( // Validate received keys against allowed keys for the specific models if (modelName == 'headline' || modelName == 'source' || - modelName == 'category' || + modelName == 'topic' || modelName == 'country') { for (final key in receivedKeys) { if (!allowedKeys.contains(key)) { @@ -247,8 +247,8 @@ Future _handleGet( sortBy: sortBy, sortOrder: sortOrder, ); - case 'category': - final repo = context.read>(); + case 'topic': + final repo = context.read>(); paginatedResponse = await repo.readAllByQuery( specificQueryForClient, userId: userIdForRepoCall, @@ -406,10 +406,10 @@ Future _handlePost( item: newItem as Headline, userId: userIdForRepoCall, ); - case 'category': - final repo = context.read>(); + case 'topic': + final repo = context.read>(); createdItem = await repo.create( - item: newItem as Category, + item: newItem as Topic, userId: userIdForRepoCall, ); case 'source': @@ -493,12 +493,12 @@ Simplified Strict Filtering Rules (ALL FILTERS ARE ANDed if present): - `q` (free-text query, searching `source.name` only) 3. Categories (`model=category`): - - Filterable __only__ by: - - `q` (free-text query, searching `category.name` only) + - Filterable __only__ by: - `q` (free-text query, searching `topic.name` + only) 4. Countries (`model=country`): - - Filterable __only__ by: - - `q` (free-text query, searching `country.name` only) + - Filterable __only__ by: - `q` (free-text query, searching `country.name` + only) ------ @@ -515,12 +515,12 @@ Explicitly Define Allowed Parameters per Model: When processing the request for Model: `headline` 1. Filter by single category: - - URL: `/api/v1/data?model=headline&categories=c1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines with category ID `c1a2b3c4-d5e6-f789-0123-456789abcdef`. + - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef` + - Expected: Headlines with topic ID `c1a2b3c4-d5e6-f789-0123-456789abcdef`. 2. Filter by multiple comma-separated categories (client-side `_in` implies OR for values): - - URL: `/api/v1/data?model=headline&categories=c1a2b3c4-d5e6-f789-0123-456789abcdef,c2b3c4d5-e6f7-a890-1234-567890abcdef` - - Expected: Headlines whose category ID is *either* of the two provided. + - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef,c2b3c4d5-e6f7-a890-1234-567890abcdef` + - Expected: Headlines whose topic ID is *either* of the two provided. 3. Filter by single source: - URL: `/api/v1/data?model=headline&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef` @@ -531,16 +531,16 @@ Model: `headline` - Expected: Headlines whose source ID is *either* of the two provided. 5. Filter by a category AND a source: - - URL: `/api/v1/data?model=headline&categories=c1a2b3c4-d5e6-f789-0123-456789abcdef&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines matching *both* the category ID AND the source ID. + - URL: `/api/v1/data?model=headline&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef&sources=s1a2b3c4-d5e6-f789-0123-456789abcdef` + - Expected: Headlines matching *both* the topic ID AND the source ID. 6. Filter by text query `q` (title only): - URL: `/api/v1/data?model=headline&q=Dart` - Expected: Headlines where "Dart" (case-insensitive) appears in the title. 7. Filter by `q` AND `categories` (q should take precedence, categories ignored): - - URL: `/api/v1/data?model=headline&q=Flutter&categories=c1a2b3c4-d5e6-f789-0123-456789abcdef` - - Expected: Headlines matching `q=Flutter` (in title), ignoring the category filter. + - URL: `/api/v1/data?model=headline&q=Flutter&topics=c1a2b3c4-d5e6-f789-0123-456789abcdef` + - Expected: Headlines matching `q=Flutter` (in title), ignoring the topic filter. 8. Invalid parameter for headlines (e.g., `countries`): - URL: `/api/v1/data?model=headline&countries=US` @@ -580,18 +580,18 @@ Model: `source` - URL: `/api/v1/data?model=source&q=Official&countries=US` - Expected: Sources matching `q=Official` (in name), ignoring the country filter. -17. Invalid parameter for sources (e.g., `categories`): - - URL: `/api/v1/data?model=source&categories=catId1` +17. Invalid parameter for sources (e.g., `topics`): + - URL: `/api/v1/data?model=source&topics=topicId1` - Expected: `400 Bad Request`. -Model: `category` +Model: `topic` 18. Filter by text query `q` for categories (name only): - - URL: `/api/v1/data?model=category&q=Mobile` - - Expected: Categories where "Mobile" appears in name. + - URL: `/api/v1/data?model=topic&q=Mobile` + - Expected: Topics where "Mobile" appears in name. 19. Invalid parameter for categories (e.g., `sources`): - - URL: `/api/v1/data?model=category&sources=sourceId1` + - URL: `/api/v1/data?model=topic&sources=sourceId1` - Expected: `400 Bad Request`. Model: `country` @@ -605,6 +605,6 @@ Model: `country` - Expected: Country with name containing "US". (Note: This test's expectation might need adjustment if no country name contains "US" but its isoCode is "US". The current `q` logic for country only searches name). 22. Invalid parameter for countries (e.g., `categories`): - - URL: `/api/v1/data?model=country&categories=catId1` + - URL: `/api/v1/data?model=country&topics=topicId1` - Expected: `400 Bad Request`. */ From 92e75fed99f8be94d33a287e5d08678672e34f50 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:09:43 +0100 Subject: [PATCH 42/48] fix(api): synchronize data route with RemoteConfig model rename Replaces all occurrences of 'AppConfig' with 'RemoteConfig' and 'app_config' with 'remote_config' in the generic data collection route handler (/api/v1/data). This change aligns the route's logic with the recent model refactoring where the AppConfig model was renamed to RemoteConfig, resolving multiple type errors. --- routes/api/v1/data/index.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 12b659e..52f32dd 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -100,7 +100,7 @@ Future onRequest(RequestContext context) async { /// Example: `/api/v1/data?model=country&q=US` (searches name and isoCode) /// - Other parameters for countries will result in a 400 Bad Request. /// -/// - **Other Models (User, UserAppSettings, UserContentPreferences, AppConfig):** +/// - **Other Models (User, UserAppSettings, UserContentPreferences, RemoteConfig):** /// - Currently support exact match for top-level query parameters passed directly. /// - No specific complex filtering logic (like `_in` or `_contains`) is applied /// by this handler for these models yet. The `HtDataInMemoryClient` can @@ -307,8 +307,8 @@ Future _handleGet( sortBy: sortBy, sortOrder: sortOrder, ); - case 'app_config': - final repo = context.read>(); + case 'remote_config': + final repo = context.read>(); paginatedResponse = await repo.readAllByQuery( specificQueryForClient, userId: userIdForRepoCall, @@ -440,10 +440,10 @@ Future _handlePost( throw const ForbiddenException( 'UserContentPreferences creation is not allowed via the generic data endpoint.', ); - case 'app_config': // New case for AppConfig (create by admin) - final repo = context.read>(); + case 'remote_config': // New case for RemoteConfig (create by admin) + final repo = context.read>(); createdItem = await repo.create( - item: newItem as AppConfig, + item: newItem as RemoteConfig, userId: userIdForRepoCall, // userId should be null for AppConfig ); default: From d429b17ef07c12c39a3e23e7b5bed2fd7e3612b8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:12:46 +0100 Subject: [PATCH 43/48] docs(api): update stale comment in data route test cases Corrects an outdated comment in the test case documentation within `routes/api/v1/data/index.dart`. The comment incorrectly stated that searching for a country via the `q` parameter only checked the name field. The implementation correctly searches both the `name` and `isoCode` fields. This change updates the comment to accurately reflect the code's behavior. --- routes/api/v1/data/index.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 52f32dd..f5959aa 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -602,7 +602,7 @@ Model: `country` 21. Filter by text query `q` for countries (name and iso_code): - URL: `/api/v1/data?model=country&q=US` - - Expected: Country with name containing "US". (Note: This test's expectation might need adjustment if no country name contains "US" but its isoCode is "US". The current `q` logic for country only searches name). + - Expected: Countries where "US" appears in the name OR the isoCode. 22. Invalid parameter for countries (e.g., `categories`): - URL: `/api/v1/data?model=country&topics=topicId1` From 72428e6ec9fd43c223c4c832b3d6d793d3be87f4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:17:34 +0100 Subject: [PATCH 44/48] fix(api): enforce camelCase for error codes in responses Updates the `_mapExceptionToCodeString` function in the error handler middleware to use `camelCase` for all generated error codes (e.g., `INVALID_INPUT` becomes `invalidInput`). This change ensures that all API error responses are fully compliant with the project's strict `camelCase` convention for JSON payloads. --- lib/src/middlewares/error_handler.dart | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/src/middlewares/error_handler.dart b/lib/src/middlewares/error_handler.dart index 7302110..2778590 100644 --- a/lib/src/middlewares/error_handler.dart +++ b/lib/src/middlewares/error_handler.dart @@ -33,7 +33,7 @@ Middleware errorHandler() { statusCode: HttpStatus.badRequest, // 400 body: { 'error': { - 'code': 'INVALID_FORMAT', + 'code': 'invalidFormat', 'message': 'Invalid data format: ${e.message}', }, }, @@ -45,7 +45,7 @@ Middleware errorHandler() { statusCode: HttpStatus.internalServerError, // 500 body: { 'error': { - 'code': 'INTERNAL_SERVER_ERROR', + 'code': 'internalServerError', 'message': 'An unexpected internal server error occurred.', // Avoid leaking sensitive details in production responses // 'details': e.toString(), // Maybe include in dev mode only @@ -78,17 +78,17 @@ int _mapExceptionToStatusCode(HtHttpException exception) { /// Maps HtHttpException subtypes to consistent error code strings. String _mapExceptionToCodeString(HtHttpException exception) { return switch (exception) { - InvalidInputException() => 'INVALID_INPUT', - AuthenticationException() => 'AUTHENTICATION_FAILED', - BadRequestException() => 'BAD_REQUEST', - UnauthorizedException() => 'UNAUTHORIZED', - ForbiddenException() => 'FORBIDDEN', - NotFoundException() => 'NOT_FOUND', - ServerException() => 'SERVER_ERROR', - OperationFailedException() => 'OPERATION_FAILED', - NetworkException() => 'NETWORK_ERROR', - ConflictException() => 'CONFLICT', - UnknownException() => 'UNKNOWN_ERROR', - _ => 'UNKNOWN_ERROR', // Default + InvalidInputException() => 'invalidInput', + AuthenticationException() => 'authenticationFailed', + BadRequestException() => 'badRequest', + UnauthorizedException() => 'unauthorized', + ForbiddenException() => 'forbidden', + NotFoundException() => 'notFound', + ServerException() => 'serverError', + OperationFailedException() => 'operationFailed', + NetworkException() => 'networkError', + ConflictException() => 'conflict', + UnknownException() => 'unknownError', + _ => 'unknownError', // Default }; } From 0d666c4d8048b311c46f9bb5a9667c83daf17b94 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:19:58 +0100 Subject: [PATCH 45/48] docs: add remote configuration to key capabilities in README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c1ad055..36d50e4 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,14 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). topics, sources) through a well-structured API that supports flexible querying and sorting for dynamic content presentation. +* 🌐 **Dynamic Remote Configuration:** Centrally manage application + behavior—including ad frequency, feature flags, and maintenance status—without + requiring a client-side update. + +* 💾 **Robust Data Management:** Securely manage core news data (headlines, + topics, sources) through a well-structured API that supports flexible + querying and sorting for dynamic content presentation. + * 📊 **Dynamic Dashboard Summary:** Access real-time, aggregated metrics on key data points like total headlines, topics, and sources, providing an at-a-glance overview for administrative dashboards. From 885379ed4332a1f92763b3dfa9c0aa8f554de05a Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:20:44 +0100 Subject: [PATCH 46/48] refactor(jwt_auth_token_service): optimize logging in validateToken and invalidateToken methods - Replace multiple _log method calls with chained calls using .. operator - Consolidate log statements in JwtAuthTokenService class - Improve code readability and reduce line count for better maintainability --- lib/src/services/jwt_auth_token_service.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 6704385..a3f60de 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -165,8 +165,8 @@ class JwtAuthTokenService implements AuthTokenService { // Fetch the full user object from the repository // This ensures the user still exists and is valid final user = await _userRepository.read(id: userId); - _log.finer('[validateToken] User repository read successful for ID: $userId'); - _log.info('[validateToken] Token validated successfully for user ${user.id}'); + _log..finer('[validateToken] User repository read successful for ID: $userId') + ..info('[validateToken] Token validated successfully for user ${user.id}'); return user; } on JWTExpiredException catch (e, s) { _log.warning('[validateToken] Token expired.', e, s); @@ -243,10 +243,10 @@ class JwtAuthTokenService implements AuthTokenService { expClaim * 1000, isUtc: true, ); - _log.finer('[invalidateToken] Extracted expiry: $expiryDateTime'); + _log..finer('[invalidateToken] Extracted expiry: $expiryDateTime') // 4. Add JTI to the blacklist - _log.finer('[invalidateToken] Adding jti $jti to blacklist...'); + ..finer('[invalidateToken] Adding jti $jti to blacklist...'); await _blacklistService.blacklist(jti, expiryDateTime); _log.info('[invalidateToken] Token (jti: $jti) successfully blacklisted.'); } on JWTException catch (e, s) { From 471c9ede37da10f3a71105f7817c98d0c0c60340 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:23:01 +0100 Subject: [PATCH 47/48] refactor(config): remove use of test-only member in EnvironmentConfig Removes the iteration over `_env.map` to resolve an `invalid_use_of_visible_for_testing_member` warning. The `.map` getter in the `dotenv` package is intended for testing only. This change respects the dependency's API contract, making the configuration loading more robust and removing the lint warning. The error message for a missing `DATABASE_URL` remains clear and actionable. --- lib/src/config/environment_config.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index b480a8e..0c6eeee 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -24,12 +24,7 @@ abstract final class EnvironmentConfig { static String get databaseUrl { final dbUrl = _env['DATABASE_URL']; if (dbUrl == null || dbUrl.isEmpty) { - _log.severe( - 'DATABASE_URL not found. Dumping available environment variables:', - ); - _env.map.forEach((key, value) { - _log.severe(' - $key: $value'); - }); + _log.severe('DATABASE_URL not found in environment variables.'); throw StateError( 'FATAL: DATABASE_URL environment variable is not set. ' 'The application cannot start without a database connection.', From 228396eaf7d4a43d1b60a5a4415b41f6510b8731 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 10 Jul 2025 15:27:44 +0100 Subject: [PATCH 48/48] style: format --- lib/src/config/app_dependencies.dart | 36 ++++-- lib/src/services/auth_service.dart | 41 +++---- .../services/database_seeding_service.dart | 19 +-- ...default_user_preference_limit_service.dart | 12 +- lib/src/services/jwt_auth_token_service.dart | 25 ++-- .../services/simple_auth_token_service.dart | 4 +- lib/src/services/token_blacklist_service.dart | 31 ++--- routes/_middleware.dart | 110 ++++++++++++------ routes/api/v1/_middleware.dart | 75 ++++++------ 9 files changed, 204 insertions(+), 149 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index a866445..7054ff0 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -40,37 +40,51 @@ class AppDependencies { // --- Repositories --- /// A repository for managing [Headline] data. late final HtDataRepository headlineRepository; + /// A repository for managing [Topic] data. late final HtDataRepository topicRepository; + /// A repository for managing [Source] data. late final HtDataRepository sourceRepository; + /// A repository for managing [Country] data. late final HtDataRepository countryRepository; + /// A repository for managing [User] data. late final HtDataRepository userRepository; + /// A repository for managing [UserAppSettings] data. late final HtDataRepository userAppSettingsRepository; + /// A repository for managing [UserContentPreferences] data. late final HtDataRepository - userContentPreferencesRepository; + userContentPreferencesRepository; + /// A repository for managing the global [RemoteConfig] data. late final HtDataRepository remoteConfigRepository; // --- Services --- /// A service for sending emails. late final HtEmailRepository emailRepository; + /// A service for managing a blacklist of invalidated authentication tokens. late final TokenBlacklistService tokenBlacklistService; + /// A service for generating and validating authentication tokens. late final AuthTokenService authTokenService; + /// A service for storing and validating one-time verification codes. late final VerificationCodeStorageService verificationCodeStorageService; + /// A service that orchestrates authentication logic. late final AuthService authService; + /// A service for calculating and providing a summary for the dashboard. late final DashboardSummaryService dashboardSummaryService; + /// A service for checking user permissions. late final PermissionService permissionService; + /// A service for enforcing limits on user content preferences. late final UserPreferenceLimitService userPreferenceLimitService; @@ -195,11 +209,13 @@ class AppDependencies { (config) { final json = config.toJson(); // All nested config objects must be JSON encoded for JSONB columns. - json['user_preference_limits'] = - jsonEncode(json['user_preference_limits']); + json['user_preference_limits'] = jsonEncode( + json['user_preference_limits'], + ); json['ad_config'] = jsonEncode(json['ad_config']); - json['account_action_config'] = - jsonEncode(json['account_action_config']); + json['account_action_config'] = jsonEncode( + json['account_action_config'], + ); json['app_status'] = jsonEncode(json['app_status']); return json; }, @@ -209,9 +225,7 @@ class AppDependencies { emailRepository = const HtEmailRepository( emailClient: HtEmailInMemoryClient(), ); - tokenBlacklistService = InMemoryTokenBlacklistService( - log: _log, - ); + tokenBlacklistService = InMemoryTokenBlacklistService(log: _log); authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, @@ -235,8 +249,10 @@ class AppDependencies { sourceRepository: sourceRepository, ); permissionService = const PermissionService(); - userPreferenceLimitService = - DefaultUserPreferenceLimitService(remoteConfigRepository: remoteConfigRepository, log: _log,); + userPreferenceLimitService = DefaultUserPreferenceLimitService( + remoteConfigRepository: remoteConfigRepository, + log: _log, + ); } HtDataRepository _createRepository( diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index ad2a023..05f53bd 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -77,7 +77,8 @@ class AuthService { ); } - final hasRequiredRole = user.dashboardRole == DashboardUserRole.admin || + final hasRequiredRole = + user.dashboardRole == DashboardUserRole.admin || user.dashboardRole == DashboardUserRole.publisher; if (!hasRequiredRole) { @@ -188,9 +189,7 @@ class AuthService { ), ); user = await _userRepository.create(item: user); - _log.info( - 'Created new user: ${user.id} with appRole: ${user.appRole}', - ); + _log.info('Created new user: ${user.id} with appRole: ${user.appRole}'); // Create default UserAppSettings for the new user final defaultAppSettings = UserAppSettings( @@ -228,7 +227,9 @@ class AuthService { item: defaultUserPreferences, userId: user.id, ); - _log.info('Created default UserContentPreferences for user: ${user.id}'); + _log.info( + 'Created default UserContentPreferences for user: ${user.id}', + ); } } on HtHttpException catch (e) { _log.severe('Error finding/creating user for $email: $e'); @@ -236,7 +237,9 @@ class AuthService { 'Failed to find or create user account.', ); } catch (e) { - _log.severe('Unexpected error during user lookup/creation for $email: $e'); + _log.severe( + 'Unexpected error during user lookup/creation for $email: $e', + ); throw const OperationFailedException('Failed to process user account.'); } @@ -272,10 +275,8 @@ class AuthService { createdAt: DateTime.now(), feedActionStatus: Map.fromEntries( FeedActionType.values.map( - (type) => MapEntry( - type, - const UserFeedActionStatus(isCompleted: false), - ), + (type) => + MapEntry(type, const UserFeedActionStatus(isCompleted: false)), ), ), ); @@ -375,25 +376,19 @@ class AuthService { try { // Invalidate the token using the AuthTokenService await _authTokenService.invalidateToken(token); - _log.info( - 'Token invalidation logic executed for user $userId.', - ); + _log.info('Token invalidation logic executed for user $userId.'); } on HtHttpException catch (_) { // Propagate known exceptions from the token service rethrow; } catch (e) { // Catch unexpected errors during token invalidation - _log.severe( - 'Error during token invalidation for user $userId: $e', - ); + _log.severe('Error during token invalidation for user $userId: $e'); throw const OperationFailedException( 'Failed server-side sign-out: Token invalidation failed.', ); } - _log.info( - 'Server-side sign-out actions complete for user $userId.', - ); + _log.info('Server-side sign-out actions complete for user $userId.'); } /// Initiates the process of linking an [emailToLink] to an existing @@ -589,9 +584,7 @@ class AuthService { await _verificationCodeStorageService.clearSignInCode( userToDelete.email, ); - _log.info( - 'Cleared sign-in code for email ${userToDelete.email}.', - ); + _log.info('Cleared sign-in code for email ${userToDelete.email}.'); } catch (e) { _log.warning( 'Warning: Failed to clear sign-in code for email ${userToDelete.email}: $e', @@ -599,9 +592,7 @@ class AuthService { } } - _log.info( - 'Account deletion process completed for user $userId.', - ); + _log.info('Account deletion process completed for user $userId.'); } on NotFoundException { // Propagate NotFoundException if user doesn't exist rethrow; diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index fdbac26..cf85361 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -305,11 +305,13 @@ class DatabaseSeedingService { ), parameters: { 'id': remoteConfig.id, - 'user_preference_config': - jsonEncode(remoteConfig.userPreferenceConfig.toJson()), + 'user_preference_config': jsonEncode( + remoteConfig.userPreferenceConfig.toJson(), + ), 'ad_config': jsonEncode(remoteConfig.adConfig.toJson()), - 'account_action_config': - jsonEncode(remoteConfig.accountActionConfig.toJson()), + 'account_action_config': jsonEncode( + remoteConfig.accountActionConfig.toJson(), + ), 'app_status': jsonEncode(remoteConfig.appStatus.toJson()), }, ); @@ -332,8 +334,9 @@ class DatabaseSeedingService { ), parameters: () { final params = adminUser.toJson(); - params['feed_action_status'] = - jsonEncode(params['feed_action_status']); + params['feed_action_status'] = jsonEncode( + params['feed_action_status'], + ); return params; }(), ); @@ -375,7 +378,9 @@ class DatabaseSeedingService { params['user_id'] = adminUser.id; params['followed_topics'] = jsonEncode(params['followed_topics']); params['followed_sources'] = jsonEncode(params['followed_sources']); - params['followed_countries'] = jsonEncode(params['followed_countries']); + params['followed_countries'] = jsonEncode( + params['followed_countries'], + ); params['saved_headlines'] = jsonEncode(params['saved_headlines']); return params; }(), diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index 6b2b5ae..74082a8 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -12,8 +12,8 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { const DefaultUserPreferenceLimitService({ required HtDataRepository remoteConfigRepository, required Logger log, - }) : _remoteConfigRepository = remoteConfigRepository, - _log = log; + }) : _remoteConfigRepository = remoteConfigRepository, + _log = log; final HtDataRepository _remoteConfigRepository; final Logger _log; @@ -29,7 +29,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ) async { try { // 1. Fetch the remote configuration to get limits - final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); final limits = remoteConfig.userPreferenceConfig; // Admins have no limits. @@ -87,7 +89,9 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { ) async { try { // 1. Fetch the remote configuration to get limits - final remoteConfig = await _remoteConfigRepository.read(id: _remoteConfigId); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); final limits = remoteConfig.userPreferenceConfig; // Admins have no limits. diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index a3f60de..5f7774f 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -165,8 +165,13 @@ class JwtAuthTokenService implements AuthTokenService { // Fetch the full user object from the repository // This ensures the user still exists and is valid final user = await _userRepository.read(id: userId); - _log..finer('[validateToken] User repository read successful for ID: $userId') - ..info('[validateToken] Token validated successfully for user ${user.id}'); + _log + ..finer( + '[validateToken] User repository read successful for ID: $userId', + ) + ..info( + '[validateToken] Token validated successfully for user ${user.id}', + ); return user; } on JWTExpiredException catch (e, s) { _log.warning('[validateToken] Token expired.', e, s); @@ -234,7 +239,9 @@ class JwtAuthTokenService implements AuthTokenService { // 3. Extract Expiry Time (exp) final expClaim = jwt.payload['exp']; if (expClaim == null || expClaim is! int) { - _log.warning('[invalidateToken] Failed: Missing or invalid "exp" claim.'); + _log.warning( + '[invalidateToken] Failed: Missing or invalid "exp" claim.', + ); throw const InvalidInputException( 'Cannot invalidate token: Missing or invalid expiry (exp) claim.', ); @@ -243,12 +250,14 @@ class JwtAuthTokenService implements AuthTokenService { expClaim * 1000, isUtc: true, ); - _log..finer('[invalidateToken] Extracted expiry: $expiryDateTime') - - // 4. Add JTI to the blacklist - ..finer('[invalidateToken] Adding jti $jti to blacklist...'); + _log + ..finer('[invalidateToken] Extracted expiry: $expiryDateTime') + // 4. Add JTI to the blacklist + ..finer('[invalidateToken] Adding jti $jti to blacklist...'); await _blacklistService.blacklist(jti, expiryDateTime); - _log.info('[invalidateToken] Token (jti: $jti) successfully blacklisted.'); + _log.info( + '[invalidateToken] Token (jti: $jti) successfully blacklisted.', + ); } on JWTException catch (e, s) { // Catch errors during the initial verification (e.g., bad signature) _log.warning( diff --git a/lib/src/services/simple_auth_token_service.dart b/lib/src/services/simple_auth_token_service.dart index 880c1cb..618a29a 100644 --- a/lib/src/services/simple_auth_token_service.dart +++ b/lib/src/services/simple_auth_token_service.dart @@ -14,8 +14,8 @@ class SimpleAuthTokenService implements AuthTokenService { const SimpleAuthTokenService({ required HtDataRepository userRepository, required Logger log, - }) : _userRepository = userRepository, - _log = log; + }) : _userRepository = userRepository, + _log = log; final HtDataRepository _userRepository; final Logger _log; diff --git a/lib/src/services/token_blacklist_service.dart b/lib/src/services/token_blacklist_service.dart index 27ec242..95bcb7f 100644 --- a/lib/src/services/token_blacklist_service.dart +++ b/lib/src/services/token_blacklist_service.dart @@ -51,16 +51,15 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { /// - [cleanupInterval]: How often the service checks for and removes /// expired token IDs. Defaults to 1 hour. InMemoryTokenBlacklistService({ - required Logger log, Duration cleanupInterval = const Duration(hours: 1), + required Logger log, + Duration cleanupInterval = const Duration(hours: 1), }) : _log = log { _cleanupTimer = Timer.periodic(cleanupInterval, (_) async { try { await cleanupExpired(); } catch (e) { // Log error during cleanup, but don't let it crash the timer - _log.severe( - 'Error during scheduled cleanup: $e', - ); + _log.severe('Error during scheduled cleanup: $e'); } }); _log.info( @@ -79,9 +78,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { @override Future blacklist(String jti, DateTime expiry) async { if (_isDisposed) { - _log.warning( - 'Attempted to blacklist on disposed service.', - ); + _log.warning('Attempted to blacklist on disposed service.'); return; } // Simulate async operation @@ -93,9 +90,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { '(expires: $expiry)', ); } catch (e) { - _log.severe( - 'Error adding jti $jti to store: $e', - ); + _log.severe('Error adding jti $jti to store: $e'); throw OperationFailedException('Failed to add token to blacklist: $e'); } } @@ -103,9 +98,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { @override Future isBlacklisted(String jti) async { if (_isDisposed) { - _log.warning( - 'Attempted to check blacklist on disposed service.', - ); + _log.warning('Attempted to check blacklist on disposed service.'); return false; } // Simulate async operation @@ -124,9 +117,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { } return true; // It's in the blacklist and not expired } catch (e) { - _log.severe( - 'Error checking blacklist for jti $jti: $e', - ); + _log.severe('Error checking blacklist for jti $jti: $e'); throw OperationFailedException('Failed to check token blacklist: $e'); } } @@ -134,9 +125,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { @override Future cleanupExpired() async { if (_isDisposed) { - _log.warning( - 'Attempted cleanup on disposed service.', - ); + _log.warning('Attempted cleanup on disposed service.'); return; } await Future.delayed(Duration.zero); // Simulate async @@ -157,9 +146,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { 'expired jti entries.', ); } else { - _log.finer( - 'Cleanup ran, no expired entries found.', - ); + _log.finer('Cleanup ran, no expired entries found.'); } } catch (e) { _log.severe('Error during cleanup process: $e'); diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 973f0a9..a86c088 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -93,7 +93,9 @@ Handler middleware(Handler handler) { // It depends on the Uuid provider, so it must come after it. .use((innerHandler) { return (context) { - _log.info('[REQ_LIFECYCLE] Request received. Generating RequestId...'); + _log.info( + '[REQ_LIFECYCLE] Request received. Generating RequestId...', + ); final uuid = context.read(); final requestId = RequestId(uuid.v4()); _log.info('[REQ_LIFECYCLE] RequestId generated: ${requestId.id}'); @@ -105,38 +107,78 @@ Handler middleware(Handler handler) { // other middleware. It's responsible for initializing and providing all // dependencies for the request. .use((handler) { - return (context) async { - // 1. Ensure all dependencies are initialized (idempotent). - _log.info('Ensuring all application dependencies are initialized...'); - await AppDependencies.instance.init(); - _log.info('Dependencies are ready.'); + return (context) async { + // 1. Ensure all dependencies are initialized (idempotent). + _log.info('Ensuring all application dependencies are initialized...'); + await AppDependencies.instance.init(); + _log.info('Dependencies are ready.'); - // 2. Provide all dependencies to the inner handler. - final deps = AppDependencies.instance; - return handler - .use(provider((_) => modelRegistry)) - .use(provider((_) => const Uuid())) - .use(provider>((_) => deps.headlineRepository)) // - .use(provider>((_) => deps.topicRepository)) - .use(provider>((_) => deps.sourceRepository)) // - .use(provider>((_) => deps.countryRepository)) // - .use(provider>((_) => deps.userRepository)) // - .use(provider>( - (_) => deps.userAppSettingsRepository)) - .use(provider>( - (_) => deps.userContentPreferencesRepository)) - .use(provider>((_) => deps.remoteConfigRepository)) - .use(provider((_) => deps.emailRepository)) - .use(provider((_) => deps.tokenBlacklistService)) - .use(provider((_) => deps.authTokenService)) - .use(provider( - (_) => deps.verificationCodeStorageService)) - .use(provider((_) => deps.authService)) - .use(provider((_) => deps.dashboardSummaryService)) - .use(provider((_) => deps.permissionService)) - .use(provider( - (_) => deps.userPreferenceLimitService)) - .call(context); - }; - }); + // 2. Provide all dependencies to the inner handler. + final deps = AppDependencies.instance; + return handler + .use(provider((_) => modelRegistry)) + .use(provider((_) => const Uuid())) + .use( + provider>( + (_) => deps.headlineRepository, + ), + ) // + .use( + provider>((_) => deps.topicRepository), + ) + .use( + provider>( + (_) => deps.sourceRepository, + ), + ) // + .use( + provider>( + (_) => deps.countryRepository, + ), + ) // + .use( + provider>((_) => deps.userRepository), + ) // + .use( + provider>( + (_) => deps.userAppSettingsRepository, + ), + ) + .use( + provider>( + (_) => deps.userContentPreferencesRepository, + ), + ) + .use( + provider>( + (_) => deps.remoteConfigRepository, + ), + ) + .use(provider((_) => deps.emailRepository)) + .use( + provider( + (_) => deps.tokenBlacklistService, + ), + ) + .use(provider((_) => deps.authTokenService)) + .use( + provider( + (_) => deps.verificationCodeStorageService, + ), + ) + .use(provider((_) => deps.authService)) + .use( + provider( + (_) => deps.dashboardSummaryService, + ), + ) + .use(provider((_) => deps.permissionService)) + .use( + provider( + (_) => deps.userPreferenceLimitService, + ), + ) + .call(context); + }; + }); } diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index e4f73e8..ddef0fe 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -20,13 +20,18 @@ bool _isOriginAllowed(String origin) { if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { // Production: strict check against the environment variable. final isAllowed = origin == allowedOriginEnv; - _log.info('[CORS] Production check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}'); + _log.info( + '[CORS] Production check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}', + ); return isAllowed; } else { // Development: dynamically allow any localhost origin. - final isAllowed = origin.startsWith('http://localhost:') || + final isAllowed = + origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:'); - _log.info('[CORS] Development check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}'); + _log.info( + '[CORS] Development check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}', + ); return isAllowed; } } @@ -36,40 +41,36 @@ Handler middleware(Handler handler) { // `/api/v1/`. The order of `.use()` is important: the last one in the // chain runs first. return handler - .use( - (handler) { - // This is a custom middleware to wrap the auth provider with logging. - final authMiddleware = authenticationProvider(); - final authHandler = authMiddleware(handler); + .use((handler) { + // This is a custom middleware to wrap the auth provider with logging. + final authMiddleware = authenticationProvider(); + final authHandler = authMiddleware(handler); - return (context) { - _log.info('[REQ_LIFECYCLE] Entering authentication middleware...'); - return authHandler(context); - }; - }, - ) - .use( - (handler) { - // This is a custom middleware to wrap the CORS provider with logging. - final corsMiddleware = fromShelfMiddleware( - shelf_cors.corsHeaders( - originChecker: _isOriginAllowed, - headers: { - shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', - shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: - 'GET, POST, PUT, DELETE, OPTIONS', - shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: - 'Origin, Content-Type, Authorization, Accept', - shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', - }, - ), - ); - final corsHandler = corsMiddleware(handler); + return (context) { + _log.info('[REQ_LIFECYCLE] Entering authentication middleware...'); + return authHandler(context); + }; + }) + .use((handler) { + // This is a custom middleware to wrap the CORS provider with logging. + final corsMiddleware = fromShelfMiddleware( + shelf_cors.corsHeaders( + originChecker: _isOriginAllowed, + headers: { + shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', + shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: + 'GET, POST, PUT, DELETE, OPTIONS', + shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: + 'Origin, Content-Type, Authorization, Accept', + shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', + }, + ), + ); + final corsHandler = corsMiddleware(handler); - return (context) { - _log.info('[REQ_LIFECYCLE] Entering CORS middleware...'); - return corsHandler(context); - }; - }, - ); + return (context) { + _log.info('[REQ_LIFECYCLE] Entering CORS middleware...'); + return corsHandler(context); + }; + }); }