diff --git a/README.md b/README.md index 1bc895d..36d50e4 100644 --- a/README.md +++ b/README.md @@ -17,28 +17,37 @@ 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 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, 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 diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 15b5ffe..7054ff0 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -38,24 +38,54 @@ class AppDependencies { final _completer = Completer(); // --- Repositories --- + /// A repository for managing [Headline] data. late final HtDataRepository headlineRepository; - late final HtDataRepository categoryRepository; + + /// 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; - late final HtDataRepository appConfigRepository; + + /// 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. @@ -100,189 +130,107 @@ 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); - }, - (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; - } - json.remove('source'); - json.remove('category'); - return 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) => headline.toJson() + ..['source_id'] = headline.source.id + ..['topic_id'] = headline.topic.id + ..['event_country_id'] = headline.eventCountry.id + ..remove('source') + ..remove('topic') + ..remove('eventCountry'), ); - 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); - }, - (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.remove('headquarters'); - return json; - }, + (json) => Source.fromJson(_convertTimestampsToString(json)), + (source) => source.toJson() + ..['headquarters_country_id'] = source.headquarters.id + ..remove('headquarters'), ); 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); - }, + UserAppSettings.fromJson, (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); - }, + UserContentPreferences.fromJson, (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. emailRepository = const HtEmailRepository( emailClient: HtEmailInMemoryClient(), ); - tokenBlacklistService = InMemoryTokenBlacklistService(); + tokenBlacklistService = InMemoryTokenBlacklistService(log: _log); authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, uuidGenerator: const Uuid(), + log: _log, ); verificationCodeStorageService = InMemoryVerificationCodeStorageService(); authService = AuthService( @@ -293,15 +241,17 @@ class AppDependencies { userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, uuidGenerator: const Uuid(), + log: _log, ); dashboardSummaryService = DashboardSummaryService( headlineRepository: headlineRepository, - categoryRepository: categoryRepository, + topicRepository: topicRepository, sourceRepository: sourceRepository, ); permissionService = const PermissionService(); userPreferenceLimitService = DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, + remoteConfigRepository: remoteConfigRepository, + log: _log, ); } @@ -321,4 +271,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; + } } diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 5570124..0c6eeee 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. @@ -25,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.', 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 }; } 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; } } 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'; } diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 7a17895..9e26b9e 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, + DashboardUserRole.admin: _dashboardAdminPermissions, }; 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 diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 34e2e21..05f53bd 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,25 +71,25 @@ 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.', ); } final hasRequiredRole = - user.roles.contains(UserRoles.admin) || - user.roles.contains(UserRoles.publisher); + user.dashboardRole == DashboardUserRole.admin || + 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 @@ -94,13 +98,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.', ); @@ -141,7 +145,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', ); } @@ -158,56 +162,94 @@ 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). - 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}'); + _log.info('Created new user: ${user.id} with appRole: ${user.appRole}'); // 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.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create( 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); + final defaultUserPreferences = UserContentPreferences( + id: user.id, + followedCountries: const [], + followedSources: const [], + followedTopics: const [], + savedHeadlines: const [], + ); await _userContentPreferencesRepository.create( 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.', ); @@ -224,47 +266,79 @@ 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}'); + _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.', ); } // 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.standard, + headlineImageStyle: HeadlineImageStyle.largeThumbnail, + showSourceInHeadlineFeed: true, + showPublishDateInHeadlineFeed: true, + ), + ); await _userAppSettingsRepository.create( 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); + 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 ); - 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.', ); @@ -294,33 +368,27 @@ 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.'); } /// Initiates the process of linking an [emailToLink] to an existing @@ -335,7 +403,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 +416,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) { @@ -372,13 +439,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( @@ -399,7 +466,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,33 +488,32 @@ 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, 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', ); } @@ -456,7 +522,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', ); } @@ -465,7 +531,7 @@ class AuthService { } on HtHttpException { rethrow; } catch (e) { - print( + _log.severe( 'Error during completeLinkEmailProcess for user ${anonymousUser.id}: $e', ); throw OperationFailedException( @@ -482,51 +548,51 @@ 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); - 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. + // 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); - 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', ); } // 3. Clear any pending sign-in codes for the user's email (if they had one). - if (userToDelete.email != null) { + // 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!, - ); - print( - '[AuthService] Cleared sign-in code for email ${userToDelete.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', ); } } - // 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. - - 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 rethrow; @@ -535,7 +601,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'); } } 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, ); } diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 57eab06..cf85361 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, + user_preference_config 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, @@ -173,25 +171,19 @@ 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(); - - // 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); + // Seed Topics + _log.fine('Seeding topics...'); + for (final topic in topicsFixturesData) { + final params = topic.toJson() + ..putIfAbsent('description', () => null) + ..putIfAbsent('icon_url', () => null) + ..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, @@ -200,18 +192,15 @@ 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); - final params = country.toJson(); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('updated_at', () => null); + for (final country in countriesFixturesData) { + final params = country.toJson() + ..putIfAbsent('updated_at', () => null); 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, @@ -220,33 +209,27 @@ class DatabaseSeedingService { // Seed Sources _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('status', () => null); - params.putIfAbsent('type', () => null); - params.putIfAbsent('updated_at', () => null); + 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 + // `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( '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', ), @@ -256,31 +239,25 @@ class DatabaseSeedingService { // Seed Headlines _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. - // These are now nullable to match the schema. - params['source_id'] = headline.source?.id; - params['category_id'] = headline.category?.id; - params.remove('source'); - params.remove('category'); - - // Ensure optional fields exist for the postgres driver. - params.putIfAbsent('description', () => null); - params.putIfAbsent('updated_at', () => null); - params.putIfAbsent('image_url', () => null); - params.putIfAbsent('url', () => null); - params.putIfAbsent('published_at', () => null); - + for (final headline in headlinesFixturesData) { + 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( - '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, @@ -303,30 +280,39 @@ 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...'); + 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 app_config (id, user_preference_limits) ' - 'VALUES (@id, @user_preference_limits) ' + 'INSERT INTO remote_config (id, user_preference_config, ad_config, ' + 'account_action_config, app_status) VALUES (@id, ' + '@user_preference_config, @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_config': jsonEncode( + remoteConfig.userPreferenceConfig.toJson(), + ), + 'ad_config': jsonEncode(remoteConfig.adConfig.toJson()), + 'account_action_config': jsonEncode( + remoteConfig.accountActionConfig.toJson(), + ), + 'app_status': jsonEncode(remoteConfig.appStatus.toJson()), }, ); @@ -334,59 +320,74 @@ class DatabaseSeedingService { _log.fine('Seeding admin user...'); // Find the admin user in the fixture data. final adminUser = usersFixturesData.firstWhere( - (user) => user.roles.contains(UserRoles.admin), + (user) => user.dashboardRole == DashboardUserRole.admin, 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. + + // 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), - }, + 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(id: adminUser.id); - final adminPreferences = UserContentPreferences(id: adminUser.id); + final adminSettings = userAppSettingsFixturesData.firstWhere( + (settings) => settings.id == adminUser.id, + ); + final adminPreferences = userContentPreferencesFixturesData.firstWhere( + (prefs) => prefs.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: () { + 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( 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: () { + 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'); - _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; diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index cc10281..74082a8 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -1,22 +1,25 @@ 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 -/// 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, + required Logger log, + }) : _remoteConfigRepository = remoteConfigRepository, + _log = log; - final HtDataRepository _appConfigRepository; + final HtDataRepository _remoteConfigRepository; + final Logger _log; - // 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 +28,37 @@ 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.userPreferenceConfig; // 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 @@ -72,7 +73,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.', ); @@ -85,37 +88,35 @@ 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.userPreferenceConfig; // 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 +132,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).', ); } @@ -148,7 +149,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/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index ea4fb24..5f7774f 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 --- @@ -59,8 +63,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, @@ -74,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', @@ -87,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 @@ -107,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})', ); @@ -130,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}).', ); @@ -146,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 @@ -155,19 +161,24 @@ 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', + ) + ..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', ); @@ -175,7 +186,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', ); @@ -184,7 +195,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', ); @@ -192,7 +203,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', @@ -202,33 +213,35 @@ 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.', ); @@ -237,15 +250,17 @@ class JwtAuthTokenService implements AuthTokenService { expClaim * 1000, isUtc: true, ); - print('[invalidateToken] Extracted expiry: $expiryDateTime'); - - // 4. Add JTI to the blacklist - print('[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); - 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', ); @@ -253,7 +268,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', ); @@ -261,7 +276,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', ); diff --git a/lib/src/services/simple_auth_token_service.dart b/lib/src/services/simple_auth_token_service.dart index 70de723..618a29a 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 diff --git a/lib/src/services/token_blacklist_service.dart b/lib/src/services/token_blacklist_service.dart index b895651..95bcb7f 100644 --- a/lib/src/services/token_blacklist_service.dart +++ b/lib/src/services/token_blacklist_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; /// {@template token_blacklist_service} @@ -50,20 +51,19 @@ 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), - }) { + }) : _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,27 +73,24 @@ 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; } // Simulate async operation 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,9 +98,7 @@ 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; } // Simulate async operation @@ -122,9 +117,7 @@ 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,9 +125,7 @@ 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; } await Future.delayed(Duration.zero); // Simulate async @@ -150,17 +141,15 @@ 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 +161,7 @@ class InMemoryTokenBlacklistService implements TokenBlacklistService { _isDisposed = true; _cleanupTimer?.cancel(); blacklistStore.clear(); - print('[InMemoryTokenBlacklistService] Disposed.'); + _log.info('Disposed.'); } } } diff --git a/routes/_middleware.dart b/routes/_middleware.dart index f9fb006..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.categoryRepository)) - .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.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); + }; + }); } 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 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.', ); 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(), ); 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 diff --git a/routes/api/v1/auth/verify-code.dart b/routes/api/v1/auth/verify-code.dart index a1fb386..09d42ec 100644 --- a/routes/api/v1/auth/verify-code.dart +++ b/routes/api/v1/auth/verify-code.dart @@ -5,11 +5,13 @@ 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, /// 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 +69,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 @@ -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 diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart index 997c74c..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`. /// @@ -23,7 +25,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.', ); @@ -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 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 diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 87fa33b..f5959aa 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(); @@ -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). @@ -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 @@ -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, @@ -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, @@ -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': @@ -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: @@ -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` @@ -602,9 +602,9 @@ 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&categories=catId1` + - URL: `/api/v1/data?model=country&topics=topicId1` - Expected: `400 Bad Request`. */