From 3f0805b5fddca5538e1a524885381292db2e98f8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 17:55:20 +0100 Subject: [PATCH 01/57] fix(api): correct middleware order for Uuid provider The Uuid provider was previously injected in `server.dart` after the root middleware from `routes/_middleware.dart` was composed. This caused a `Bad state` error because the middleware attempted to read the Uuid from the context before it was provided. This change moves the Uuid provider to the beginning of the middleware chain in `routes/_middleware.dart`, ensuring it is available for all subsequent middleware and route handlers that depend on it for generating request IDs. The redundant provider has been removed from `server.dart`. --- lib/src/config/server.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 8853b36..0c728f2 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -183,8 +183,6 @@ Future run(Handler handler, InternetAddress ip, int port) async { // 6. Create the main handler with all dependencies provided final finalHandler = handler - // Foundational utilities - .use(provider((_) => uuid)) // Repositories .use(provider>((_) => headlineRepository)) .use(provider>((_) => categoryRepository)) From 3a7b29812a5bfa8238cfbc7d0052d1a907f8f694 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 17:58:23 +0100 Subject: [PATCH 02/57] fix(api): correct middleware order for Uuid provider The Uuid provider was previously injected in `server.dart` after the root middleware from `routes/_middleware.dart` was composed. This caused a `Bad state` error because the middleware attempted to read the Uuid from the context before it was provided. This change moves the Uuid provider to the beginning of the middleware chain in `routes/_middleware.dart`, ensuring it is available for all subsequent middleware and route handlers that depend on it for generating request IDs. The redundant provider has been removed from `server.dart`. --- routes/_middleware.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 56ccd46..5c8df63 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -53,9 +53,10 @@ Handler middleware(Handler handler) { // 3. Error Handler: Catches all errors and formats them into a standard // JSON response. return handler + .use(provider((_) => const Uuid())) .use((innerHandler) { return (context) { - // Read the singleton Uuid instance provided from server.dart. + // Read the Uuid instance provided from this middleware chain. final uuid = context.read(); final requestId = RequestId(uuid.v4()); return innerHandler(context.provide(() => requestId)); From 4c7031f1a6a7652369015624dd62063b2c2c7e54 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:12:47 +0100 Subject: [PATCH 03/57] feat(api): implement dynamic CORS for local development Replaces the static CORS configuration with a dynamic, per-request policy. This new middleware is production-ready and developer-friendly: - In production, it strictly enforces the origin specified in the `CORS_ALLOWED_ORIGIN` environment variable. - In development (if the variable is not set), it inspects the request's `Origin` header and automatically allows any request from `localhost` on any port. This fixes issues with the Flutter web dev server using random ports and removes the need for manual configuration during local development. --- routes/api/v1/_middleware.dart | 108 +++++++++++++++++---------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index f7a57b1..ef4eec2 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -4,64 +4,66 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/authentication_middleware.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart' as shelf_cors; +// This middleware is implemented as a higher-order function that returns a +// handler. This approach is necessary to dynamically configure CORS headers +// based on the incoming request's 'Origin' header, which is only available +// within the request context. Handler middleware(Handler handler) { - // This middleware applies providers and CORS handling to all routes - // under /api/v1/. + return (RequestContext context) { + // --- Dynamic CORS Configuration --- + final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; + final requestOrigin = context.request.headers['Origin']; + String? effectiveOrigin; - // --- CORS Configuration --- - final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; - String effectiveOrigin; + if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { + // PRODUCTION: Use the strictly defined origin from the env variable. + effectiveOrigin = allowedOriginEnv; + } else { + // DEVELOPMENT: Dynamically allow any localhost or 127.0.0.1 origin. + // This is crucial because the Flutter web dev server runs on a random + // port for each session. + if (requestOrigin != null && + (requestOrigin.startsWith('http://localhost:') || + requestOrigin.startsWith('http://127.0.0.1:'))) { + effectiveOrigin = requestOrigin; + } else { + // For other clients (like Postman) or if origin is missing in dev, + // there's no specific origin to return. The CORS middleware will + // simply not add the 'Access-Control-Allow-Origin' header if the + // request's origin doesn't match, which is fine. + effectiveOrigin = null; + } + } - if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { - effectiveOrigin = allowedOriginEnv; - print( - '[CORS Middleware] Using Access-Control-Allow-Origin from ' - 'CORS_ALLOWED_ORIGIN environment variable: "$effectiveOrigin"', - ); - } else { - // IMPORTANT: Default for local development ONLY if env var is not set. - // You MUST set CORS_ALLOWED_ORIGIN in production for security. - // This default allows credentials, so it cannot be '*'. - // Adjust 'http://localhost:3000' if your local Flutter web dev server - // typically runs on a different port. - effectiveOrigin = 'http://localhost:39155'; - print('------------------------------------------------------------------'); - print('WARNING: CORS_ALLOWED_ORIGIN environment variable is NOT SET.'); - print( - 'Defaulting Access-Control-Allow-Origin to: "$effectiveOrigin" ' - 'FOR DEVELOPMENT ONLY.', - ); - print( - 'For production, you MUST set the CORS_ALLOWED_ORIGIN environment ' - "variable to your Flutter web application's specific domain.", - ); - print('------------------------------------------------------------------'); - } + final corsConfig = { + // Crucial for authenticated APIs where the frontend sends credentials + // (e.g., Authorization header with fetch({ credentials: 'include' })) + shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', + // Define allowed HTTP methods + shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: + 'GET, POST, PUT, DELETE, OPTIONS', + // Define allowed headers from the client + shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: + 'Origin, Content-Type, Authorization, Accept', + // Optional: How long the results of a preflight request can be cached + shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', // 24 hours + }; - final corsConfig = { - // Use the determined origin (from env var or development default) - shelf_cors.ACCESS_CONTROL_ALLOW_ORIGIN: effectiveOrigin, - // Crucial for authenticated APIs where the frontend sends credentials - // (e.g., Authorization header with fetch({ credentials: 'include' })) - shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', - // Define allowed HTTP methods - shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: 'GET, POST, PUT, DELETE, OPTIONS', - // Define allowed headers from the client - shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: - 'Origin, Content-Type, Authorization, Accept', - // Optional: How long the results of a preflight request can be cached - shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', // 24 hours - }; + if (effectiveOrigin != null) { + corsConfig[shelf_cors.ACCESS_CONTROL_ALLOW_ORIGIN] = effectiveOrigin; + } - // Apply CORS middleware first. - // `fromShelfMiddleware` adapts the Shelf-based CORS middleware for Dart Frog. - var newHandler = handler.use( - fromShelfMiddleware(shelf_cors.corsHeaders(headers: corsConfig)), - ); + // The handler chain needs to be built and executed within this context. + // Order: CORS -> Auth -> Route Handler + final corsMiddleware = + fromShelfMiddleware(shelf_cors.corsHeaders(headers: corsConfig)); + final authMiddleware = authenticationProvider(); - // Then apply the authenticationProvider. - // ignore: join_return_with_assignment - newHandler = newHandler.use(authenticationProvider()); + // Chain the middlewares and the original handler together. + // The request will flow through corsMiddleware, then authMiddleware, + // then the original handler. + final composedHandler = handler.use(authMiddleware).use(corsMiddleware); - return newHandler; + return composedHandler(context); + }; } From ab9fb6d6170b5788f485f0a5cf5d8b294941c5d1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:14:18 +0100 Subject: [PATCH 04/57] docs(api): update README with new dynamic CORS behavior Updated the "Note on Web Client Integration (CORS)" section to reflect the new dynamic CORS handling for local development. The documentation now correctly states that `localhost` origins are automatically allowed on any port during development, and the `CORS_ALLOWED_ORIGIN` variable is only required for production environments. --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4c0f833..1bc895d 100644 --- a/README.md +++ b/README.md @@ -99,13 +99,14 @@ for more details. CONFLICT DO NOTHING` to avoid overwriting existing tables or data. - **Note on Web Client Integration (CORS):** - To allow web applications (like the HT Dashboard) to connect to this API, - the `CORS_ALLOWED_ORIGIN` environment variable must be set to the - specific origin of your web application (e.g., `https://your-dashboard.com`). - For local development, if this variable is not set, the API defaults to - allowing `http://localhost:3000` and issues a console warning. See the - `routes/api/v1/_middleware.dart` file for the exact implementation details. + **Note on Web Client Integration (CORS):** To allow web applications (like + the HT Dashboard) to connect to this API in production, the + `CORS_ALLOWED_ORIGIN` environment variable must be set to the specific + origin of your web application (e.g., `https://your-dashboard.com`). + + For local development, the API automatically allows any request + originating from `localhost` on any port, so you do not need to set this + variable. ## ✅ Testing From 07363bbb397551e42c26eadb6627e1948a62f162 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:14:54 +0100 Subject: [PATCH 05/57] style: format --- routes/api/v1/_middleware.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index ef4eec2..684bda2 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -55,8 +55,9 @@ Handler middleware(Handler handler) { // The handler chain needs to be built and executed within this context. // Order: CORS -> Auth -> Route Handler - final corsMiddleware = - fromShelfMiddleware(shelf_cors.corsHeaders(headers: corsConfig)); + final corsMiddleware = fromShelfMiddleware( + shelf_cors.corsHeaders(headers: corsConfig), + ); final authMiddleware = authenticationProvider(); // Chain the middlewares and the original handler together. From 7f1c001c857aeee5fa865bc4adc4c431faf14154 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:26:03 +0100 Subject: [PATCH 06/57] fix(api): correct root middleware execution order for Uuid provider The middleware chain in `routes/_middleware.dart` was ordered incorrectly, causing the `RequestId` provider to execute before the `Uuid` provider. This resulted in a `Bad state: context.read()` error on every request. This change reorders the `.use()` calls to ensure the `Uuid` provider is applied to the context first, making it available to the `RequestId` provider and resolving the crash. --- routes/_middleware.dart | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 5c8df63..e2ecb7d 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -52,16 +52,17 @@ Handler middleware(Handler handler) { // 2. Request Logger: Logs request and response details. // 3. Error Handler: Catches all errors and formats them into a standard // JSON response. - return handler - .use(provider((_) => const Uuid())) - .use((innerHandler) { - return (context) { - // Read the Uuid instance provided from this middleware chain. - final uuid = context.read(); - final requestId = RequestId(uuid.v4()); - return innerHandler(context.provide(() => requestId)); - }; - }) - .use(requestLogger()) - .use(errorHandler()); + return handler.use(errorHandler()).use(requestLogger()).use((innerHandler) { + // This middleware reads the Uuid and provides the RequestId. + // It must come after the Uuid provider in the chain. + return (context) { + // Read the Uuid instance provided from the previous middleware. + final uuid = context.read(); + final requestId = RequestId(uuid.v4()); + return innerHandler(context.provide(() => requestId)); + }; + }).use( + // This provider is last in the chain, so it runs first. + provider((_) => const Uuid()), + ); } From 8c2e5ce94bc8d0c2d6f3f64486610b5079820c6c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:26:23 +0100 Subject: [PATCH 07/57] format: style --- routes/_middleware.dart | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index e2ecb7d..e8a7b59 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -52,17 +52,21 @@ Handler middleware(Handler handler) { // 2. Request Logger: Logs request and response details. // 3. Error Handler: Catches all errors and formats them into a standard // JSON response. - return handler.use(errorHandler()).use(requestLogger()).use((innerHandler) { - // This middleware reads the Uuid and provides the RequestId. - // It must come after the Uuid provider in the chain. - return (context) { - // Read the Uuid instance provided from the previous middleware. - final uuid = context.read(); - final requestId = RequestId(uuid.v4()); - return innerHandler(context.provide(() => requestId)); - }; - }).use( - // This provider is last in the chain, so it runs first. - provider((_) => const Uuid()), - ); + return handler + .use(errorHandler()) + .use(requestLogger()) + .use((innerHandler) { + // This middleware reads the Uuid and provides the RequestId. + // It must come after the Uuid provider in the chain. + return (context) { + // Read the Uuid instance provided from the previous middleware. + final uuid = context.read(); + final requestId = RequestId(uuid.v4()); + return innerHandler(context.provide(() => requestId)); + }; + }) + .use( + // This provider is last in the chain, so it runs first. + provider((_) => const Uuid()), + ); } From 0e056ef8d92879db7bd5b5bbb2d024a8198a5e71 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:38:31 +0100 Subject: [PATCH 08/57] fix(api): correct v1 middleware composition to preserve context The previous implementation of the `/api/v1` middleware used a higher-order function that created an isolated handler chain, breaking the flow of the request context from `server.dart`. This caused `Bad state` errors when the authentication middleware tried to read services like `AuthTokenService`. This change refactors the middleware to use a standard `.use()` chain. It now leverages the `originChecker` callback from `shelf_cors_headers` to handle dynamic CORS policies correctly within a standard composition pattern. This ensures the request context is preserved, and all downstream middleware can access the globally provided services. --- lib/src/config/server.dart | 5 +- routes/api/v1/_middleware.dart | 106 ++++++++++++++------------------- 2 files changed, 46 insertions(+), 65 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index 0c728f2..e1cf3bc 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -150,7 +150,6 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); // 5. Initialize Services - const uuid = Uuid(); const emailRepository = HtEmailRepository( emailClient: HtEmailInMemoryClient(), ); @@ -158,7 +157,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { final authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, - uuidGenerator: uuid, + uuidGenerator: const Uuid(), ); final verificationCodeStorageService = InMemoryVerificationCodeStorageService(); @@ -169,7 +168,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { emailRepository: emailRepository, userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: uuid, + uuidGenerator: const Uuid(), ); final dashboardSummaryService = DashboardSummaryService( headlineRepository: headlineRepository, diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index 684bda2..850a4db 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -4,67 +4,49 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/authentication_middleware.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart' as shelf_cors; -// This middleware is implemented as a higher-order function that returns a -// handler. This approach is necessary to dynamically configure CORS headers -// based on the incoming request's 'Origin' header, which is only available -// within the request context. -Handler middleware(Handler handler) { - return (RequestContext context) { - // --- Dynamic CORS Configuration --- - final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; - final requestOrigin = context.request.headers['Origin']; - String? effectiveOrigin; - - if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { - // PRODUCTION: Use the strictly defined origin from the env variable. - effectiveOrigin = allowedOriginEnv; - } else { - // DEVELOPMENT: Dynamically allow any localhost or 127.0.0.1 origin. - // This is crucial because the Flutter web dev server runs on a random - // port for each session. - if (requestOrigin != null && - (requestOrigin.startsWith('http://localhost:') || - requestOrigin.startsWith('http://127.0.0.1:'))) { - effectiveOrigin = requestOrigin; - } else { - // For other clients (like Postman) or if origin is missing in dev, - // there's no specific origin to return. The CORS middleware will - // simply not add the 'Access-Control-Allow-Origin' header if the - // request's origin doesn't match, which is fine. - effectiveOrigin = null; - } - } - - final corsConfig = { - // Crucial for authenticated APIs where the frontend sends credentials - // (e.g., Authorization header with fetch({ credentials: 'include' })) - shelf_cors.ACCESS_CONTROL_ALLOW_CREDENTIALS: 'true', - // Define allowed HTTP methods - shelf_cors.ACCESS_CONTROL_ALLOW_METHODS: - 'GET, POST, PUT, DELETE, OPTIONS', - // Define allowed headers from the client - shelf_cors.ACCESS_CONTROL_ALLOW_HEADERS: - 'Origin, Content-Type, Authorization, Accept', - // Optional: How long the results of a preflight request can be cached - shelf_cors.ACCESS_CONTROL_MAX_AGE: '86400', // 24 hours - }; - - if (effectiveOrigin != null) { - corsConfig[shelf_cors.ACCESS_CONTROL_ALLOW_ORIGIN] = effectiveOrigin; - } - - // The handler chain needs to be built and executed within this context. - // Order: CORS -> Auth -> Route Handler - final corsMiddleware = fromShelfMiddleware( - shelf_cors.corsHeaders(headers: corsConfig), - ); - final authMiddleware = authenticationProvider(); - - // Chain the middlewares and the original handler together. - // The request will flow through corsMiddleware, then authMiddleware, - // then the original handler. - final composedHandler = handler.use(authMiddleware).use(corsMiddleware); +/// Checks if the request's origin is allowed based on the environment. +/// +/// In production (when `CORS_ALLOWED_ORIGIN` is set), it performs a strict +/// check against the specified origin. +/// In development, it dynamically allows any `localhost` or `127.0.0.1` +/// origin to support the Flutter web dev server's random ports. +bool _isOriginAllowed(String origin) { + final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; + + if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { + // Production: strict check against the environment variable. + return origin == allowedOriginEnv; + } else { + // Development: dynamically allow any localhost origin. + return origin.startsWith('http://localhost:') || + origin.startsWith('http://127.0.0.1:'); + } +} - return composedHandler(context); - }; +Handler middleware(Handler handler) { + // This middleware applies CORS and authentication to all routes under + // `/api/v1/`. The order of `.use()` is important: the last one in the + // chain runs first. + return handler + // 2. The authentication middleware runs after CORS, using the services + // provided from server.dart. + .use(authenticationProvider()) + // 1. The CORS middleware runs first. It uses an `originChecker` to + // dynamically handle origins, which is the correct way to manage + // CORS in a standard middleware chain. + .use( + 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', + }, + ), + ), + ); } From 9a0258470fbe20e5cbfe59a52b498920493b1153 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:46:06 +0100 Subject: [PATCH 09/57] feat(api): add dependency container for service location Introduces a singleton `DependencyContainer` to act as a centralized service locator. This is a crucial architectural change to fix dependency injection issues caused by the Dart Frog middleware lifecycle. - The container is initialized once at startup in `server.dart`. - The root middleware will use it to provide all dependencies to the request context. - Includes extensive documentation explaining the rationale and fixing the "context.read() called too early" problem. --- lib/src/config/dependency_container.dart | 115 +++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 lib/src/config/dependency_container.dart diff --git a/lib/src/config/dependency_container.dart b/lib/src/config/dependency_container.dart new file mode 100644 index 0000000..f890583 --- /dev/null +++ b/lib/src/config/dependency_container.dart @@ -0,0 +1,115 @@ +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/services/auth_service.dart'; +import 'package:ht_api/src/services/auth_token_service.dart'; +import 'package:ht_api/src/services/dashboard_summary_service.dart'; +import 'package:ht_api/src/services/token_blacklist_service.dart'; +import 'package:ht_api/src/services/user_preference_limit_service.dart'; +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'; + +/// {@template dependency_container} +/// A singleton service locator for managing and providing access to all shared +/// application dependencies (repositories and services). +/// +/// **Rationale for this pattern in Dart Frog:** +/// +/// In Dart Frog, middleware defined in the `routes` directory is composed and +/// initialized *before* the custom `run` function in `lib/src/config/server.dart` +/// is executed. This creates a lifecycle challenge: if you try to provide +/// dependencies using `handler.use(provider<...>)` inside `server.dart`, any +/// middleware from the `routes` directory that needs those dependencies will +/// fail because it runs too early. +/// +/// This `DependencyContainer` solves the problem by acting as a centralized, +/// globally accessible holder for all dependencies. +/// +/// **The Dependency Injection Flow:** +/// +/// 1. **Initialization (`server.dart`):** The `run` function in `server.dart` +/// initializes all repositories and services. +/// 2. **Population (`server.dart`):** It then calls `DependencyContainer.instance.init(...)` +/// to populate this singleton with the initialized instances. This happens +/// only once at server startup. +/// 3. **Provision (`routes/_middleware.dart`):** The root middleware, which +/// runs for every request, accesses the initialized dependencies from +/// `DependencyContainer.instance` and uses `context.provide()` to +/// inject them into the request's context. +/// 4. **Consumption (Other Middleware/Routes):** All subsequent middleware +/// (like the authentication middleware) and route handlers can now safely +/// read the dependencies from the context using `context.read()`. +/// +/// This pattern ensures that dependencies are created once and are available +/// throughout the entire request lifecycle, respecting Dart Frog's execution +/// order. +/// {@endtemplate} +class DependencyContainer { + // Private constructor for the singleton pattern. + DependencyContainer._(); + + /// The single, global instance of the [DependencyContainer]. + static final instance = DependencyContainer._(); + + // --- Repositories --- + late final HtDataRepository headlineRepository; + late final HtDataRepository categoryRepository; + late final HtDataRepository sourceRepository; + late final HtDataRepository countryRepository; + late final HtDataRepository userRepository; + late final HtDataRepository userAppSettingsRepository; + late final HtDataRepository + userContentPreferencesRepository; + late final HtDataRepository appConfigRepository; + late final HtEmailRepository emailRepository; + + // --- Services --- + late final TokenBlacklistService tokenBlacklistService; + late final AuthTokenService authTokenService; + late final VerificationCodeStorageService verificationCodeStorageService; + late final AuthService authService; + late final DashboardSummaryService dashboardSummaryService; + late final PermissionService permissionService; + late final UserPreferenceLimitService userPreferenceLimitService; + + /// Initializes the container with all the required dependencies. + /// + /// This method must be called exactly once at server startup from within + /// the `run` function in `server.dart`. + void init({ + required HtDataRepository headlineRepository, + required HtDataRepository categoryRepository, + required HtDataRepository sourceRepository, + required HtDataRepository countryRepository, + required HtDataRepository userRepository, + required HtDataRepository userAppSettingsRepository, + required HtDataRepository + userContentPreferencesRepository, + required HtDataRepository appConfigRepository, + required HtEmailRepository emailRepository, + required TokenBlacklistService tokenBlacklistService, + required AuthTokenService authTokenService, + required VerificationCodeStorageService verificationCodeStorageService, + required AuthService authService, + required DashboardSummaryService dashboardSummaryService, + required PermissionService permissionService, + required UserPreferenceLimitService userPreferenceLimitService, + }) { + this.headlineRepository = headlineRepository; + this.categoryRepository = categoryRepository; + this.sourceRepository = sourceRepository; + this.countryRepository = countryRepository; + this.userRepository = userRepository; + this.userAppSettingsRepository = userAppSettingsRepository; + this.userContentPreferencesRepository = userContentPreferencesRepository; + this.appConfigRepository = appConfigRepository; + this.emailRepository = emailRepository; + this.tokenBlacklistService = tokenBlacklistService; + this.authTokenService = authTokenService; + this.verificationCodeStorageService = verificationCodeStorageService; + this.authService = authService; + this.dashboardSummaryService = dashboardSummaryService; + this.permissionService = permissionService; + this.userPreferenceLimitService = userPreferenceLimitService; + } +} From 2880ad64a50b26fea5fc36ae5de05818e8e8cce4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:48:02 +0100 Subject: [PATCH 10/57] refactor(api): populate dependency container from server This change modifies the server startup process to populate the newly created DependencyContainer singleton with all initialized repositories and services. It removes the old, ineffective provider chain that was causing dependency injection failures due to the Dart Frog middleware lifecycle. --- lib/src/config/server.dart | 65 +++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index e1cf3bc..85bca45 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/dependency_container.dart'; import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; @@ -180,43 +181,35 @@ Future run(Handler handler, InternetAddress ip, int port) async { appConfigRepository: appConfigRepository, ); - // 6. Create the main handler with all dependencies provided - final finalHandler = handler - // Repositories - .use(provider>((_) => headlineRepository)) - .use(provider>((_) => categoryRepository)) - .use(provider>((_) => sourceRepository)) - .use(provider>((_) => countryRepository)) - .use(provider>((_) => userRepository)) - .use( - provider>( - (_) => userAppSettingsRepository, - ), - ) - .use( - provider>( - (_) => userContentPreferencesRepository, - ), - ) - .use(provider>((_) => appConfigRepository)) - .use(provider((_) => emailRepository)) - // Services - .use(provider((_) => tokenBlacklistService)) - .use(provider((_) => authTokenService)) - .use( - provider( - (_) => verificationCodeStorageService, - ), - ) - .use(provider((_) => authService)) - .use(provider((_) => dashboardSummaryService)) - .use(provider((_) => permissionService)) - .use( - provider((_) => userPreferenceLimitService), - ); + // 6. Populate the DependencyContainer with all initialized instances. + // This must be done before the server starts handling requests, as the + // root middleware will read from this container to provide dependencies. + DependencyContainer.instance.init( + // Repositories + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + countryRepository: countryRepository, + userRepository: userRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + appConfigRepository: appConfigRepository, + emailRepository: emailRepository, + // Services + tokenBlacklistService: tokenBlacklistService, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + authService: authService, + dashboardSummaryService: dashboardSummaryService, + permissionService: permissionService, + userPreferenceLimitService: userPreferenceLimitService, + ); - // 7. Start the server - final server = await serve(finalHandler, ip, port); + // 7. Start the server. + // The original `handler` from Dart Frog is used. The root middleware in + // `routes/_middleware.dart` will now be responsible for injecting all the + // dependencies from the `DependencyContainer` into the request context. + final server = await serve(handler, ip, port); _log.info('Server listening on port ${server.port}'); // 8. Handle graceful shutdown From ae9a641039a51103aaeeefcdd2cb8aee64e3644a Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:49:54 +0100 Subject: [PATCH 11/57] fix(api): provide all dependencies from root middleware Refactors the root middleware to provide all shared repositories and services to the request context. It reads the initialized instances from the `DependencyContainer` singleton, which is populated at startup. This change completes the architectural refactoring to a centralized dependency injection model, ensuring that all dependencies are available to downstream middleware and route handlers, finally resolving the "context.read() called too early" errors. --- routes/_middleware.dart | 110 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index e8a7b59..1e2b553 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,5 +1,16 @@ import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/dependency_container.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/services/auth_service.dart'; +import 'package:ht_api/src/services/auth_token_service.dart'; +import 'package:ht_api/src/services/dashboard_summary_service.dart'; +import 'package:ht_api/src/services/token_blacklist_service.dart'; +import 'package:ht_api/src/services/user_preference_limit_service.dart'; +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:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -46,27 +57,104 @@ class RequestId { // --- Middleware Definition --- Handler middleware(Handler handler) { - // This is the root middleware chain for the entire API. - // The order is important: - // 1. Request ID: Assigns a unique ID to each request for tracing. - // 2. Request Logger: Logs request and response details. - // 3. Error Handler: Catches all errors and formats them into a standard - // JSON response. + // This is the root middleware for the entire API. It's responsible for + // providing all shared dependencies to the request context. + // The order of `.use()` calls is important: the last one in the chain + // runs first. return handler + // --- Core Middleware --- + // These run after all dependencies have been provided. .use(errorHandler()) .use(requestLogger()) + // --- Request ID Provider --- + // This middleware provides a unique ID for each request for tracing. + // It depends on the Uuid provider, so it must come after it. .use((innerHandler) { - // This middleware reads the Uuid and provides the RequestId. - // It must come after the Uuid provider in the chain. return (context) { - // Read the Uuid instance provided from the previous middleware. final uuid = context.read(); final requestId = RequestId(uuid.v4()); return innerHandler(context.provide(() => requestId)); }; }) + // --- Dependency Providers --- + // These providers inject all repositories and services into the context. + // They read from the `DependencyContainer` which was populated at startup. + // This is the first set of middleware to run for any request. + .use(provider((_) => const Uuid())) .use( - // This provider is last in the chain, so it runs first. - provider((_) => const Uuid()), + provider>( + (_) => DependencyContainer.instance.headlineRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.categoryRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.sourceRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.countryRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.userRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.userAppSettingsRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.userContentPreferencesRepository, + ), + ) + .use( + provider>( + (_) => DependencyContainer.instance.appConfigRepository, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.emailRepository, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.tokenBlacklistService, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.authTokenService, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.verificationCodeStorageService, + ), + ) + .use(provider((_) => DependencyContainer.instance.authService)) + .use( + provider( + (_) => DependencyContainer.instance.dashboardSummaryService, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.permissionService, + ), + ) + .use( + provider( + (_) => DependencyContainer.instance.userPreferenceLimitService, + ), ); } From 0f2e38aa1f609ef3240166f2baf9d9b25e402292 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 18:56:17 +0100 Subject: [PATCH 12/57] fix(api): implement custom server entrypoint to fix init race condition Refactors the server startup logic into a custom `bin/server.dart` entrypoint. This resolves a `LateInitializationError` caused by a race condition where requests could be processed before the asynchronous dependency initialization was complete. - All async setup (DB connection, seeding, service creation) is now completed and `await`ed in `main()`. - The `DependencyContainer` is populated only after all services are ready. - The Dart Frog request handler is built and served only after all initialization is finished. - Deletes the old, now-redundant `lib/src/config/server.dart`. --- {lib/src/config => bin}/server.dart | 44 +++++++++-------------------- 1 file changed, 13 insertions(+), 31 deletions(-) rename {lib/src/config => bin}/server.dart (80%) diff --git a/lib/src/config/server.dart b/bin/server.dart similarity index 80% rename from lib/src/config/server.dart rename to bin/server.dart index 85bca45..fe930f9 100644 --- a/lib/src/config/server.dart +++ b/bin/server.dart @@ -23,16 +23,17 @@ import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; -/// Global logger instance. +// This is the generated file from Dart Frog. +// We need to import it to get the `buildRootHandler` function. +import '../.dart_frog/server.dart'; + +// Global logger instance. final _log = Logger('ht_api'); -/// Global PostgreSQL connection instance. +// Global PostgreSQL connection instance. late final Connection _connection; -/// Creates a data repository for a given type [T]. -/// -/// This helper function centralizes the creation of repositories, -/// ensuring they all use the same database connection and logger. +// Creates a data repository for a given type [T]. HtDataRepository _createRepository({ required String tableName, required FromJson fromJson, @@ -49,14 +50,7 @@ HtDataRepository _createRepository({ ); } -/// The main entry point for the server. -/// -/// This function is responsible for: -/// 1. Setting up the global logger. -/// 2. Establishing the PostgreSQL database connection. -/// 3. Providing these dependencies to the Dart Frog handler. -/// 4. Gracefully closing the database connection on server shutdown. -Future run(Handler handler, InternetAddress ip, int port) async { +Future main() async { // 1. Setup Logger Logger.root.level = Level.ALL; Logger.root.onRecord.listen((record) { @@ -88,17 +82,11 @@ Future run(Handler handler, InternetAddress ip, int port) async { username: username, password: password, ), - // Using `require` is a more secure default. For local development against - // a non-SSL database, this may need to be changed to `SslMode.disable`. settings: const ConnectionSettings(sslMode: SslMode.require), ); _log.info('PostgreSQL database connection established.'); // 3. Initialize and run database seeding - // This runs on every startup. The operations are idempotent (`IF NOT EXISTS`, - // `ON CONFLICT DO NOTHING`), so it's safe to run every time. This ensures - // the database is always in a valid state, especially for first-time setup - // in any environment. final seedingService = DatabaseSeedingService( connection: _connection, log: _log, @@ -181,11 +169,8 @@ Future run(Handler handler, InternetAddress ip, int port) async { appConfigRepository: appConfigRepository, ); - // 6. Populate the DependencyContainer with all initialized instances. - // This must be done before the server starts handling requests, as the - // root middleware will read from this container to provide dependencies. + // 6. Populate the DependencyContainer DependencyContainer.instance.init( - // Repositories headlineRepository: headlineRepository, categoryRepository: categoryRepository, sourceRepository: sourceRepository, @@ -195,7 +180,6 @@ Future run(Handler handler, InternetAddress ip, int port) async { userContentPreferencesRepository: userContentPreferencesRepository, appConfigRepository: appConfigRepository, emailRepository: emailRepository, - // Services tokenBlacklistService: tokenBlacklistService, authTokenService: authTokenService, verificationCodeStorageService: verificationCodeStorageService, @@ -205,10 +189,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { userPreferenceLimitService: userPreferenceLimitService, ); - // 7. Start the server. - // The original `handler` from Dart Frog is used. The root middleware in - // `routes/_middleware.dart` will now be responsible for injecting all the - // dependencies from the `DependencyContainer` into the request context. + // 7. Build the handler and start the server + final ip = InternetAddress.anyIPv4; + final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final handler = buildRootHandler(); final server = await serve(handler, ip, port); _log.info('Server listening on port ${server.port}'); @@ -221,6 +205,4 @@ Future run(Handler handler, InternetAddress ip, int port) async { _log.info('Server shut down.'); exit(0); }); - - return server; } From 60d9563d5e8b42f577ebcf3f40cf13c0fec1e600 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:01:59 +0100 Subject: [PATCH 13/57] fix(api): resolve startup race condition with initialization gate Implements an initialization gate using a `Completer` in `bin/server.dart` to prevent a race condition where requests could be processed before asynchronous setup (DB connection, dependency injection) was complete. - A new `_initializationGate` middleware now wraps the root handler. - This middleware awaits a `Completer`'s future, effectively pausing all incoming requests. - The completer is only marked as complete after all async initialization in `main()` is finished and the server is listening. - This guarantees that the `DependencyContainer` is fully populated before any request attempts to access it, resolving the `LateInitializationError`. --- bin/server.dart | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/bin/server.dart b/bin/server.dart index fe930f9..c50952c 100644 --- a/bin/server.dart +++ b/bin/server.dart @@ -1,17 +1,15 @@ -import 'dart:io'; +import 'dart:async'; +import 'dart:io' show InternetAddress, Platform, ProcessSignal, exit; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/dependency_container.dart'; import 'package:ht_api/src/config/environment_config.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/dashboard_summary_service.dart'; -import 'package:ht_api/src/services/database_seeding_service.dart'; import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; import 'package:ht_api/src/services/jwt_auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; -import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; import 'package:ht_data_client/ht_data_client.dart'; import 'package:ht_data_postgres/ht_data_postgres.dart'; @@ -21,6 +19,7 @@ import 'package:ht_email_repository/ht_email_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; +import 'package:ht_api/src/services/database_seeding_service.dart'; import 'package:uuid/uuid.dart'; // This is the generated file from Dart Frog. @@ -33,6 +32,27 @@ final _log = Logger('ht_api'); // Global PostgreSQL connection instance. late final Connection _connection; +/// A completer that signals when all asynchronous server initialization is +/// complete. +/// +/// This is used by the [_initializationGate] middleware to hold requests +/// until the server is fully ready to process them, preventing race conditions +/// where a request arrives before the database is connected or dependencies +/// are initialized. +final _initCompleter = Completer(); + +/// A top-level middleware that waits for the async initialization to complete +/// before processing any requests. +/// +/// This acts as a "gate," ensuring that no request is handled until the future +/// in [_initCompleter] is completed. +Handler _initializationGate(Handler innerHandler) { + return (context) async { + await _initCompleter.future; + return innerHandler(context); + }; +} + // Creates a data repository for a given type [T]. HtDataRepository _createRepository({ required String tableName, @@ -192,10 +212,17 @@ Future main() async { // 7. Build the handler and start the server final ip = InternetAddress.anyIPv4; final port = int.parse(Platform.environment['PORT'] ?? '8080'); - final handler = buildRootHandler(); + // The root handler from Dart Frog is wrapped with our initialization gate. + // This ensures that `_initializationGate` runs for every request, pausing + // it until `_initCompleter` is marked as complete. + final handler = _initializationGate(buildRootHandler()); final server = await serve(handler, ip, port); _log.info('Server listening on port ${server.port}'); + // Now that the server is running, we complete the completer to open the gate + // and allow requests to be processed. + _initCompleter.complete(); + // 8. Handle graceful shutdown ProcessSignal.sigint.watch().listen((_) async { _log.info('Received SIGINT. Shutting down...'); From 9860121961e19e69980cd4ca7f134fe4e887e634 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:11:51 +0100 Subject: [PATCH 14/57] fix(api): implement correct server entrypoint with initialization gate Refactors the server startup logic into a custom `run` function in `lib/src/config/server.dart` to align with the `dart_frog dev` lifecycle. This change resolves a `LateInitializationError` caused by a race condition where requests could be processed before asynchronous dependency setup was complete. - All async setup (DB connection, seeding, service creation) is now completed within the `run` function. - A `Completer`-based "initialization gate" middleware is used to hold all incoming requests until the server is fully initialized and the `DependencyContainer` is populated. - Deletes the unused `bin/server.dart` file. --- lib/src/config/server.dart | 234 +++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 lib/src/config/server.dart diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart new file mode 100644 index 0000000..d8f881e --- /dev/null +++ b/lib/src/config/server.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/config/dependency_container.dart'; +import 'package:ht_api/src/config/environment_config.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/services/auth_service.dart'; +import 'package:ht_api/src/services/auth_token_service.dart'; +import 'package:ht_api/src/services/dashboard_summary_service.dart'; +import 'package:ht_api/src/services/database_seeding_service.dart'; +import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; +import 'package:ht_api/src/services/jwt_auth_token_service.dart'; +import 'package:ht_api/src/services/token_blacklist_service.dart'; +import 'package:ht_api/src/services/user_preference_limit_service.dart'; +import 'package:ht_api/src/services/verification_code_storage_service.dart'; +import 'package:ht_data_client/ht_data_client.dart'; +import 'package:ht_data_postgres/ht_data_postgres.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_email_inmemory/ht_email_inmemory.dart'; +import 'package:ht_email_repository/ht_email_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; +import 'package:uuid/uuid.dart'; + +/// Global logger instance. +final _log = Logger('ht_api'); + +/// Global PostgreSQL connection instance. +late final Connection _connection; + +/// Creates a data repository for a given type [T]. +HtDataRepository _createRepository({ + required String tableName, + required FromJson fromJson, + required ToJson toJson, +}) { + return HtDataRepository( + dataClient: HtDataPostgresClient( + connection: _connection, + tableName: tableName, + fromJson: fromJson, + toJson: toJson, + log: _log, + ), + ); +} + +/// The main entry point for the server, used by `dart_frog dev`. +/// +/// This function is responsible for the entire server startup sequence: +/// 1. **Gating Requests:** It immediately sets up a "gate" using a `Completer` +/// to hold all incoming requests until initialization is complete. +/// 2. **Async Initialization:** It performs all necessary asynchronous setup, +/// including logging, database connection, and data seeding. +/// 3. **Dependency Injection:** It initializes all repositories and services +/// and populates the `DependencyContainer`. +/// 4. **Server Start:** It starts the HTTP server with the gated handler. +/// 5. **Opening the Gate:** Once the server is listening, it completes the +/// `Completer`, allowing the gated requests to be processed. +/// 6. **Graceful Shutdown:** It sets up a listener for `SIGINT` to close +/// resources gracefully. +Future run(Handler handler, InternetAddress ip, int port) async { + final initCompleter = Completer(); + + // This middleware wraps the main handler. It awaits the completer's future, + // effectively pausing the request until `initCompleter.complete()` is called. + final gatedHandler = handler.use( + (innerHandler) { + return (context) async { + await initCompleter.future; + return innerHandler(context); + }; + }, + ); + + // 1. Setup Logger + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '${record.level.name}: ${record.time}: ' + '${record.loggerName}: ${record.message}', + ); + }); + + // 2. Establish Database Connection + _log.info('Connecting to PostgreSQL database...'); + final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); + String? username; + String? password; + if (dbUri.userInfo.isNotEmpty) { + final parts = dbUri.userInfo.split(':'); + username = Uri.decodeComponent(parts.first); + if (parts.length > 1) { + password = Uri.decodeComponent(parts.last); + } + } + + _connection = await Connection.open( + Endpoint( + host: dbUri.host, + port: dbUri.port, + database: dbUri.path.substring(1), // Remove leading '/' + username: username, + password: password, + ), + settings: const ConnectionSettings(sslMode: SslMode.require), + ); + _log.info('PostgreSQL database connection established.'); + + // 3. Initialize and run database seeding + final seedingService = DatabaseSeedingService( + connection: _connection, + log: _log, + ); + await seedingService.createTables(); + await seedingService.seedGlobalFixtureData(); + await seedingService.seedInitialAdminAndConfig(); + + // 4. Initialize Repositories + final headlineRepository = _createRepository( + tableName: 'headlines', + fromJson: Headline.fromJson, + toJson: (h) => h.toJson(), + ); + final categoryRepository = _createRepository( + tableName: 'categories', + fromJson: Category.fromJson, + toJson: (c) => c.toJson(), + ); + final sourceRepository = _createRepository( + tableName: 'sources', + fromJson: Source.fromJson, + toJson: (s) => s.toJson(), + ); + final countryRepository = _createRepository( + tableName: 'countries', + fromJson: Country.fromJson, + toJson: (c) => c.toJson(), + ); + final userRepository = _createRepository( + tableName: 'users', + fromJson: User.fromJson, + toJson: (u) => u.toJson(), + ); + final userAppSettingsRepository = _createRepository( + tableName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (s) => s.toJson(), + ); + final userContentPreferencesRepository = + _createRepository( + tableName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (p) => p.toJson(), + ); + final appConfigRepository = _createRepository( + tableName: 'app_config', + fromJson: AppConfig.fromJson, + toJson: (c) => c.toJson(), + ); + + // 5. Initialize Services + const emailRepository = HtEmailRepository( + emailClient: HtEmailInMemoryClient(), + ); + final tokenBlacklistService = InMemoryTokenBlacklistService(); + final authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + uuidGenerator: const Uuid(), + ); + final verificationCodeStorageService = + InMemoryVerificationCodeStorageService(); + final authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + uuidGenerator: const Uuid(), + ); + final dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); + const permissionService = PermissionService(); + final userPreferenceLimitService = DefaultUserPreferenceLimitService( + appConfigRepository: appConfigRepository, + ); + + // 6. Populate the DependencyContainer + DependencyContainer.instance.init( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + countryRepository: countryRepository, + userRepository: userRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + appConfigRepository: appConfigRepository, + emailRepository: emailRepository, + tokenBlacklistService: tokenBlacklistService, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + authService: authService, + dashboardSummaryService: dashboardSummaryService, + permissionService: permissionService, + userPreferenceLimitService: userPreferenceLimitService, + ); + + // 7. Start the server with the gated handler + final server = await serve(gatedHandler, ip, port); + _log.info('Server listening on port ${server.port}'); + + // 8. Open the gate now that the server is ready. + initCompleter.complete(); + + // 9. Handle graceful shutdown + ProcessSignal.sigint.watch().listen((_) async { + _log.info('Received SIGINT. Shutting down...'); + await _connection.close(); + _log.info('Database connection closed.'); + await server.close(force: true); + _log.info('Server shut down.'); + exit(0); + }); + + return server; +} \ No newline at end of file From ebf04d8cc4c61de3b9057b63e4cfd46301e19cb6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:12:48 +0100 Subject: [PATCH 15/57] refactor(api): remove server.dart file and related initialization logic --- bin/server.dart | 235 ------------------------------------------------ 1 file changed, 235 deletions(-) delete mode 100644 bin/server.dart diff --git a/bin/server.dart b/bin/server.dart deleted file mode 100644 index c50952c..0000000 --- a/bin/server.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:async'; -import 'dart:io' show InternetAddress, Platform, ProcessSignal, exit; - -import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/config/dependency_container.dart'; -import 'package:ht_api/src/config/environment_config.dart'; -import 'package:ht_api/src/rbac/permission_service.dart'; -import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_api/src/services/dashboard_summary_service.dart'; -import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; -import 'package:ht_api/src/services/jwt_auth_token_service.dart'; -import 'package:ht_api/src/services/token_blacklist_service.dart'; -import 'package:ht_api/src/services/verification_code_storage_service.dart'; -import 'package:ht_data_client/ht_data_client.dart'; -import 'package:ht_data_postgres/ht_data_postgres.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_email_inmemory/ht_email_inmemory.dart'; -import 'package:ht_email_repository/ht_email_repository.dart'; -import 'package:ht_shared/ht_shared.dart'; -import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; -import 'package:ht_api/src/services/database_seeding_service.dart'; -import 'package:uuid/uuid.dart'; - -// This is the generated file from Dart Frog. -// We need to import it to get the `buildRootHandler` function. -import '../.dart_frog/server.dart'; - -// Global logger instance. -final _log = Logger('ht_api'); - -// Global PostgreSQL connection instance. -late final Connection _connection; - -/// A completer that signals when all asynchronous server initialization is -/// complete. -/// -/// This is used by the [_initializationGate] middleware to hold requests -/// until the server is fully ready to process them, preventing race conditions -/// where a request arrives before the database is connected or dependencies -/// are initialized. -final _initCompleter = Completer(); - -/// A top-level middleware that waits for the async initialization to complete -/// before processing any requests. -/// -/// This acts as a "gate," ensuring that no request is handled until the future -/// in [_initCompleter] is completed. -Handler _initializationGate(Handler innerHandler) { - return (context) async { - await _initCompleter.future; - return innerHandler(context); - }; -} - -// Creates a data repository for a given type [T]. -HtDataRepository _createRepository({ - required String tableName, - required FromJson fromJson, - required ToJson toJson, -}) { - return HtDataRepository( - dataClient: HtDataPostgresClient( - connection: _connection, - tableName: tableName, - fromJson: fromJson, - toJson: toJson, - log: _log, - ), - ); -} - -Future main() async { - // 1. Setup Logger - Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((record) { - // ignore: avoid_print - print( - '${record.level.name}: ${record.time}: ' - '${record.loggerName}: ${record.message}', - ); - }); - - // 2. Establish Database Connection - _log.info('Connecting to PostgreSQL database...'); - final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); - String? username; - String? password; - if (dbUri.userInfo.isNotEmpty) { - final parts = dbUri.userInfo.split(':'); - username = Uri.decodeComponent(parts.first); - if (parts.length > 1) { - password = Uri.decodeComponent(parts.last); - } - } - - _connection = await Connection.open( - Endpoint( - host: dbUri.host, - port: dbUri.port, - database: dbUri.path.substring(1), // Remove leading '/' - username: username, - password: password, - ), - settings: const ConnectionSettings(sslMode: SslMode.require), - ); - _log.info('PostgreSQL database connection established.'); - - // 3. Initialize and run database seeding - final seedingService = DatabaseSeedingService( - connection: _connection, - log: _log, - ); - await seedingService.createTables(); - await seedingService.seedGlobalFixtureData(); - await seedingService.seedInitialAdminAndConfig(); - - // 4. Initialize Repositories - final headlineRepository = _createRepository( - tableName: 'headlines', - fromJson: Headline.fromJson, - toJson: (h) => h.toJson(), - ); - final categoryRepository = _createRepository( - tableName: 'categories', - fromJson: Category.fromJson, - toJson: (c) => c.toJson(), - ); - final sourceRepository = _createRepository( - tableName: 'sources', - fromJson: Source.fromJson, - toJson: (s) => s.toJson(), - ); - final countryRepository = _createRepository( - tableName: 'countries', - fromJson: Country.fromJson, - toJson: (c) => c.toJson(), - ); - final userRepository = _createRepository( - tableName: 'users', - fromJson: User.fromJson, - toJson: (u) => u.toJson(), - ); - final userAppSettingsRepository = _createRepository( - tableName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (s) => s.toJson(), - ); - final userContentPreferencesRepository = - _createRepository( - tableName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (p) => p.toJson(), - ); - final appConfigRepository = _createRepository( - tableName: 'app_config', - fromJson: AppConfig.fromJson, - toJson: (c) => c.toJson(), - ); - - // 5. Initialize Services - const emailRepository = HtEmailRepository( - emailClient: HtEmailInMemoryClient(), - ); - final tokenBlacklistService = InMemoryTokenBlacklistService(); - final authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - uuidGenerator: const Uuid(), - ); - final verificationCodeStorageService = - InMemoryVerificationCodeStorageService(); - final authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - emailRepository: emailRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: const Uuid(), - ); - final dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); - const permissionService = PermissionService(); - final userPreferenceLimitService = DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, - ); - - // 6. Populate the DependencyContainer - DependencyContainer.instance.init( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - countryRepository: countryRepository, - userRepository: userRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - appConfigRepository: appConfigRepository, - emailRepository: emailRepository, - tokenBlacklistService: tokenBlacklistService, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - authService: authService, - dashboardSummaryService: dashboardSummaryService, - permissionService: permissionService, - userPreferenceLimitService: userPreferenceLimitService, - ); - - // 7. Build the handler and start the server - final ip = InternetAddress.anyIPv4; - final port = int.parse(Platform.environment['PORT'] ?? '8080'); - // The root handler from Dart Frog is wrapped with our initialization gate. - // This ensures that `_initializationGate` runs for every request, pausing - // it until `_initCompleter` is marked as complete. - final handler = _initializationGate(buildRootHandler()); - final server = await serve(handler, ip, port); - _log.info('Server listening on port ${server.port}'); - - // Now that the server is running, we complete the completer to open the gate - // and allow requests to be processed. - _initCompleter.complete(); - - // 8. Handle graceful shutdown - ProcessSignal.sigint.watch().listen((_) async { - _log.info('Received SIGINT. Shutting down...'); - await _connection.close(); - _log.info('Database connection closed.'); - await server.close(force: true); - _log.info('Server shut down.'); - exit(0); - }); -} From edb47ad2036a94481769ad968776b0fc6eb053cf Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:14:08 +0100 Subject: [PATCH 16/57] refactor(api): explicitly type service variables in server config Improves code clarity and robustness in `server.dart` by explicitly typing service variables with their abstract base classes (e.g., `AuthTokenService`) instead of relying on type inference for their concrete implementations. This makes dependency contracts clearer and aligns with best practices. --- lib/src/config/server.dart | 44 +++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/lib/src/config/server.dart b/lib/src/config/server.dart index d8f881e..0729302 100644 --- a/lib/src/config/server.dart +++ b/lib/src/config/server.dart @@ -66,14 +66,12 @@ Future run(Handler handler, InternetAddress ip, int port) async { // This middleware wraps the main handler. It awaits the completer's future, // effectively pausing the request until `initCompleter.complete()` is called. - final gatedHandler = handler.use( - (innerHandler) { - return (context) async { - await initCompleter.future; - return innerHandler(context); - }; - }, - ); + final gatedHandler = handler.use((innerHandler) { + return (context) async { + await initCompleter.future; + return innerHandler(context); + }; + }); // 1. Setup Logger Logger.root.level = Level.ALL; @@ -152,10 +150,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); final userContentPreferencesRepository = _createRepository( - tableName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (p) => p.toJson(), - ); + tableName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (p) => p.toJson(), + ); final appConfigRepository = _createRepository( tableName: 'app_config', fromJson: AppConfig.fromJson, @@ -167,7 +165,7 @@ Future run(Handler handler, InternetAddress ip, int port) async { emailClient: HtEmailInMemoryClient(), ); final tokenBlacklistService = InMemoryTokenBlacklistService(); - final authTokenService = JwtAuthTokenService( + final AuthTokenService authTokenService = JwtAuthTokenService( userRepository: userRepository, blacklistService: tokenBlacklistService, uuidGenerator: const Uuid(), @@ -183,15 +181,17 @@ Future run(Handler handler, InternetAddress ip, int port) async { userContentPreferencesRepository: userContentPreferencesRepository, uuidGenerator: const Uuid(), ); - final dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); + final dashboardSummaryService = + DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); const permissionService = PermissionService(); - final userPreferenceLimitService = DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, - ); + final UserPreferenceLimitService userPreferenceLimitService = + DefaultUserPreferenceLimitService( + appConfigRepository: appConfigRepository, + ); // 6. Populate the DependencyContainer DependencyContainer.instance.init( @@ -231,4 +231,4 @@ Future run(Handler handler, InternetAddress ip, int port) async { }); return server; -} \ No newline at end of file +} From 6013ff86a9904bfd4a438701abcc11fb9a87fbc4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:18:25 +0100 Subject: [PATCH 17/57] chore: moved server.dart from config folder to directly under the lib folder --- lib/{src/config => }/server.dart | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/{src/config => }/server.dart (100%) diff --git a/lib/src/config/server.dart b/lib/server.dart similarity index 100% rename from lib/src/config/server.dart rename to lib/server.dart From 7dbf541bbe2f530e71d9b693391e7c3549d3ae5c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:26:45 +0100 Subject: [PATCH 18/57] feat(api): add detailed logging to server initialization sequence Instruments the `run` function in `lib/server.dart` with detailed, sequential log messages. This provides clear visibility into the server startup process, from logger setup and database connection to dependency creation and the opening of the request gate. This will aid in debugging initialization-related issues. --- lib/server.dart | 50 +++++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/lib/server.dart b/lib/server.dart index 0729302..cfb14db 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -82,9 +82,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { '${record.loggerName}: ${record.message}', ); }); + _log.info('[INIT_SEQ] 1. Logger setup complete.'); // 2. Establish Database Connection - _log.info('Connecting to PostgreSQL database...'); + _log.info('[INIT_SEQ] 2. Connecting to PostgreSQL database...'); final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); String? username; String? password; @@ -106,18 +107,24 @@ Future run(Handler handler, InternetAddress ip, int port) async { ), settings: const ConnectionSettings(sslMode: SslMode.require), ); - _log.info('PostgreSQL database connection established.'); + _log.info('[INIT_SEQ] 2. PostgreSQL database connection established.'); // 3. Initialize and run database seeding + _log.info('[INIT_SEQ] 3. Initializing database seeding service...'); final seedingService = DatabaseSeedingService( connection: _connection, log: _log, ); + _log.info('[INIT_SEQ] 3. Creating tables if they do not exist...'); await seedingService.createTables(); + _log.info('[INIT_SEQ] 3. Seeding global fixture data...'); await seedingService.seedGlobalFixtureData(); + _log.info('[INIT_SEQ] 3. Seeding initial admin and config...'); await seedingService.seedInitialAdminAndConfig(); - - // 4. Initialize Repositories + _log + ..info('[INIT_SEQ] 3. Database seeding complete.') + // 4. Initialize Repositories + ..info('[INIT_SEQ] 4. Initializing data repositories...'); final headlineRepository = _createRepository( tableName: 'headlines', fromJson: Headline.fromJson, @@ -159,8 +166,10 @@ Future run(Handler handler, InternetAddress ip, int port) async { fromJson: AppConfig.fromJson, toJson: (c) => c.toJson(), ); - - // 5. Initialize Services + _log + ..info('[INIT_SEQ] 4. Data repositories initialized.') + // 5. Initialize Services + ..info('[INIT_SEQ] 5. Initializing services...'); const emailRepository = HtEmailRepository( emailClient: HtEmailInMemoryClient(), ); @@ -181,19 +190,20 @@ Future run(Handler handler, InternetAddress ip, int port) async { userContentPreferencesRepository: userContentPreferencesRepository, uuidGenerator: const Uuid(), ); - final dashboardSummaryService = - DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); + final dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); const permissionService = PermissionService(); final UserPreferenceLimitService userPreferenceLimitService = DefaultUserPreferenceLimitService( appConfigRepository: appConfigRepository, ); - - // 6. Populate the DependencyContainer + _log + ..info('[INIT_SEQ] 5. Services initialized.') + // 6. Populate the DependencyContainer + ..info('[INIT_SEQ] 6. Populating dependency container...'); DependencyContainer.instance.init( headlineRepository: headlineRepository, categoryRepository: categoryRepository, @@ -214,21 +224,25 @@ Future run(Handler handler, InternetAddress ip, int port) async { ); // 7. Start the server with the gated handler + _log.info('[INIT_SEQ] 7. Starting server with gated handler...'); final server = await serve(gatedHandler, ip, port); - _log.info('Server listening on port ${server.port}'); + _log.info('[INIT_SEQ] 7. Server listening on port ${server.port}'); // 8. Open the gate now that the server is ready. + _log.info('[INIT_SEQ] 8. Server ready. Opening request gate.'); initCompleter.complete(); // 9. Handle graceful shutdown + _log.info('[INIT_SEQ] 9. Registering graceful shutdown handler.'); ProcessSignal.sigint.watch().listen((_) async { - _log.info('Received SIGINT. Shutting down...'); + _log.info('[SHUTDOWN] Received SIGINT. Shutting down...'); await _connection.close(); - _log.info('Database connection closed.'); + _log.info('[SHUTDOWN] Database connection closed.'); await server.close(force: true); - _log.info('Server shut down.'); + _log.info('[SHUTDOWN] Server shut down.'); exit(0); }); + _log.info('[INIT_SEQ] Server initialization sequence complete.'); return server; } From 3d565326282c3610d6e1ea081ad32c3ac8a4ae0c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:27:57 +0100 Subject: [PATCH 19/57] feat(api): add confirmation logging to dependency container Adds a log message to the `init` method of the `DependencyContainer`. This provides an explicit log entry confirming that the container has been successfully populated with all service and repository instances during the server startup sequence. --- lib/src/config/dependency_container.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/config/dependency_container.dart b/lib/src/config/dependency_container.dart index f890583..f656e18 100644 --- a/lib/src/config/dependency_container.dart +++ b/lib/src/config/dependency_container.dart @@ -1,3 +1,5 @@ +// ignore_for_file: public_member_api_docs + import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; @@ -8,6 +10,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'; /// {@template dependency_container} /// A singleton service locator for managing and providing access to all shared @@ -51,6 +54,8 @@ class DependencyContainer { /// The single, global instance of the [DependencyContainer]. static final instance = DependencyContainer._(); + final _log = Logger('DependencyContainer'); + // --- Repositories --- late final HtDataRepository headlineRepository; late final HtDataRepository categoryRepository; @@ -111,5 +116,7 @@ class DependencyContainer { this.dashboardSummaryService = dashboardSummaryService; this.permissionService = permissionService; this.userPreferenceLimitService = userPreferenceLimitService; + + _log.info('[INIT_SEQ] 6. Dependency container populated successfully.'); } } From 1a0a3bfdd05cb36c91806c06cefc6d6e6a44b087 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:28:38 +0100 Subject: [PATCH 20/57] feat(api): add logging to root middleware for request tracing Instruments the root middleware (`routes/_middleware.dart`) with logging to trace the start of the request lifecycle. This includes logging the generation of a unique `RequestId` for each incoming request, improving debuggability and traceability. --- routes/_middleware.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 1e2b553..27e0766 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -11,6 +11,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'; // --- Request ID Wrapper --- @@ -56,6 +57,8 @@ class RequestId { } // --- Middleware Definition --- +final _log = Logger('RootMiddleware'); + Handler middleware(Handler handler) { // This is the root middleware for the entire API. It's responsible for // providing all shared dependencies to the request context. @@ -71,8 +74,10 @@ 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...'); final uuid = context.read(); final requestId = RequestId(uuid.v4()); + _log.info('[REQ_LIFECYCLE] RequestId generated: ${requestId.id}'); return innerHandler(context.provide(() => requestId)); }; }) From 78e1030fcfaf8394b3deaa993aff8de554995f14 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:30:38 +0100 Subject: [PATCH 21/57] feat(api): add logging to v1 middleware for CORS and auth tracing Instruments the API v1 middleware (`routes/api/v1/_middleware.dart`) with logging. This adds visibility into the CORS origin checking logic and confirms when a request enters the CORS and authentication middleware handlers, completing the request lifecycle trace. --- routes/api/v1/_middleware.dart | 65 +++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/routes/api/v1/_middleware.dart b/routes/api/v1/_middleware.dart index 850a4db..e4f73e8 100644 --- a/routes/api/v1/_middleware.dart +++ b/routes/api/v1/_middleware.dart @@ -2,8 +2,11 @@ import 'dart:io' show Platform; // To read environment variables import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/authentication_middleware.dart'; +import 'package:logging/logging.dart'; import 'package:shelf_cors_headers/shelf_cors_headers.dart' as shelf_cors; +final _log = Logger('ApiV1Middleware'); + /// Checks if the request's origin is allowed based on the environment. /// /// In production (when `CORS_ALLOWED_ORIGIN` is set), it performs a strict @@ -11,15 +14,20 @@ import 'package:shelf_cors_headers/shelf_cors_headers.dart' as shelf_cors; /// In development, it dynamically allows any `localhost` or `127.0.0.1` /// origin to support the Flutter web dev server's random ports. bool _isOriginAllowed(String origin) { + _log.info('[CORS] Checking origin: "$origin"'); final allowedOriginEnv = Platform.environment['CORS_ALLOWED_ORIGIN']; if (allowedOriginEnv != null && allowedOriginEnv.isNotEmpty) { // Production: strict check against the environment variable. - return origin == allowedOriginEnv; + final isAllowed = origin == allowedOriginEnv; + _log.info('[CORS] Production check result: ${isAllowed ? 'ALLOWED' : 'DENIED'}'); + return isAllowed; } else { // Development: dynamically allow any localhost origin. - return 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'}'); + return isAllowed; } } @@ -28,25 +36,40 @@ Handler middleware(Handler handler) { // `/api/v1/`. The order of `.use()` is important: the last one in the // chain runs first. return handler - // 2. The authentication middleware runs after CORS, using the services - // provided from server.dart. - .use(authenticationProvider()) - // 1. The CORS middleware runs first. It uses an `originChecker` to - // dynamically handle origins, which is the correct way to manage - // CORS in a standard middleware chain. .use( - 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', - }, - ), - ), + (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 CORS middleware...'); + return corsHandler(context); + }; + }, ); } From 5712bf90ab5de421eabd776855d1cfef056870c8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:34:37 +0100 Subject: [PATCH 22/57] refactor(api): centralize DI in root middleware Refactors the dependency injection mechanism to be handled entirely within the root middleware (`routes/_middleware.dart`). - All services and repositories are now initialized asynchronously within the middleware for each request. - Uses the `DatabaseConnectionManager` singleton to ensure a single, shared database connection. - This approach is more robust and aligned with Dart Frog's lifecycle, removing the reliance on a custom `run` function and the `DependencyContainer` singleton. --- routes/_middleware.dart | 235 ++++++++++++++++++++++++++-------------- 1 file changed, 154 insertions(+), 81 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 27e0766..7865c17 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,17 +1,24 @@ import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/config/dependency_container.dart'; +import 'package:ht_api/src/config/database_connection.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/dashboard_summary_service.dart'; +import 'package:ht_api/src/services/database_seeding_service.dart'; +import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; +import 'package:ht_api/src/services/jwt_auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; +import 'package:ht_data_client/ht_data_client.dart'; +import 'package:ht_data_postgres/ht_data_postgres.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_email_inmemory/ht_email_inmemory.dart'; import 'package:ht_email_repository/ht_email_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -59,6 +66,24 @@ class RequestId { // --- Middleware Definition --- final _log = Logger('RootMiddleware'); +/// Creates a data repository for a given type [T]. +HtDataRepository _createRepository({ + required Connection connection, + required String tableName, + required FromJson fromJson, + required ToJson toJson, +}) { + return HtDataRepository( + dataClient: HtDataPostgresClient( + connection: connection, + tableName: tableName, + fromJson: fromJson, + toJson: toJson, + log: _log, + ), + ); +} + Handler middleware(Handler handler) { // This is the root middleware for the entire API. It's responsible for // providing all shared dependencies to the request context. @@ -81,85 +106,133 @@ Handler middleware(Handler handler) { return innerHandler(context.provide(() => requestId)); }; }) - // --- Dependency Providers --- - // These providers inject all repositories and services into the context. - // They read from the `DependencyContainer` which was populated at startup. - // This is the first set of middleware to run for any request. - .use(provider((_) => const Uuid())) - .use( - provider>( - (_) => DependencyContainer.instance.headlineRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.categoryRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.sourceRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.countryRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.userRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.userAppSettingsRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.userContentPreferencesRepository, - ), - ) - .use( - provider>( - (_) => DependencyContainer.instance.appConfigRepository, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.emailRepository, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.tokenBlacklistService, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.authTokenService, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.verificationCodeStorageService, - ), - ) - .use(provider((_) => DependencyContainer.instance.authService)) - .use( - provider( - (_) => DependencyContainer.instance.dashboardSummaryService, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.permissionService, - ), - ) - .use( - provider( - (_) => DependencyContainer.instance.userPreferenceLimitService, - ), + // --- Dependency Provider --- + // This is the outermost middleware. It runs once per request, before any + // other middleware. It's responsible for initializing and providing all + // dependencies for the request. + .use((handler) { + return (context) async { + // 1. Ensure the database connection is initialized. + await DatabaseConnectionManager.instance.init(); + final connection = await DatabaseConnectionManager.instance.connection; + + // 2. Run database seeding (idempotent). + final seedingService = DatabaseSeedingService( + connection: connection, + log: _log, + ); + await seedingService.createTables(); + await seedingService.seedGlobalFixtureData(); + await seedingService.seedInitialAdminAndConfig(); + + // 3. Initialize Repositories. + final headlineRepository = _createRepository( + connection: connection, + tableName: 'headlines', + fromJson: Headline.fromJson, + toJson: (h) => h.toJson(), + ); + final categoryRepository = _createRepository( + connection: connection, + tableName: 'categories', + fromJson: Category.fromJson, + toJson: (c) => c.toJson(), + ); + final sourceRepository = _createRepository( + connection: connection, + tableName: 'sources', + fromJson: Source.fromJson, + toJson: (s) => s.toJson(), + ); + final countryRepository = _createRepository( + connection: connection, + tableName: 'countries', + fromJson: Country.fromJson, + toJson: (c) => c.toJson(), + ); + final userRepository = _createRepository( + connection: connection, + tableName: 'users', + fromJson: User.fromJson, + toJson: (u) => u.toJson(), + ); + final userAppSettingsRepository = _createRepository( + connection: connection, + tableName: 'user_app_settings', + fromJson: UserAppSettings.fromJson, + toJson: (s) => s.toJson(), ); + final userContentPreferencesRepository = + _createRepository( + connection: connection, + tableName: 'user_content_preferences', + fromJson: UserContentPreferences.fromJson, + toJson: (p) => p.toJson(), + ); + final appConfigRepository = _createRepository( + connection: connection, + tableName: 'app_config', + fromJson: AppConfig.fromJson, + toJson: (c) => c.toJson(), + ); + + // 4. Initialize Services. + const emailRepository = HtEmailRepository( + emailClient: HtEmailInMemoryClient(), + ); + final tokenBlacklistService = InMemoryTokenBlacklistService(); + final AuthTokenService authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + uuidGenerator: const Uuid(), + ); + final verificationCodeStorageService = + InMemoryVerificationCodeStorageService(); + final authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + uuidGenerator: const Uuid(), + ); + final dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); + const permissionService = PermissionService(); + final UserPreferenceLimitService userPreferenceLimitService = + DefaultUserPreferenceLimitService( + appConfigRepository: appConfigRepository, + ); + + // 5. Provide all dependencies to the inner handler. + return handler + .use(provider((_) => const Uuid())) + .use(provider>((_) => headlineRepository)) + .use(provider>((_) => categoryRepository)) + .use(provider>((_) => sourceRepository)) + .use(provider>((_) => countryRepository)) + .use(provider>((_) => userRepository)) + .use(provider>( + (_) => userAppSettingsRepository)) + .use(provider>( + (_) => userContentPreferencesRepository)) + .use(provider>((_) => appConfigRepository)) + .use(provider((_) => emailRepository)) + .use(provider((_) => tokenBlacklistService)) + .use(provider((_) => authTokenService)) + .use(provider( + (_) => verificationCodeStorageService)) + .use(provider((_) => authService)) + .use(provider((_) => dashboardSummaryService)) + .use(provider((_) => permissionService)) + .use(provider( + (_) => userPreferenceLimitService)) + .call(context); + }; + }); +} } From 2ceed312b2a0043424872df11d68ef0d3eb3426b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:35:35 +0100 Subject: [PATCH 23/57] refactor(middleware): remove trailing semicolon --- routes/_middleware.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 7865c17..dcd4df6 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -235,4 +235,4 @@ Handler middleware(Handler handler) { }; }); } -} + From 41d0726ec96cb27bef98f063feb4c32e96584edc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:38:28 +0100 Subject: [PATCH 24/57] feat(api): create singleton for database connection management Introduces `DatabaseConnectionManager`, a singleton class to manage a single, shared PostgreSQL connection for the application. This ensures the database is connected only once, improving performance and resource management. It uses a `Completer` to make the connection available asynchronously and includes logging for the initialization process. --- lib/src/config/database_connection.dart | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 lib/src/config/database_connection.dart diff --git a/lib/src/config/database_connection.dart b/lib/src/config/database_connection.dart new file mode 100644 index 0000000..36e85c8 --- /dev/null +++ b/lib/src/config/database_connection.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:ht_api/src/config/environment_config.dart'; +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; + +/// A singleton class to manage a single, shared PostgreSQL database connection. +/// +/// This pattern ensures that the application establishes a connection to the +/// database only once and reuses it for all subsequent operations, which is +/// crucial for performance and resource management. +class DatabaseConnectionManager { + // Private constructor for the singleton pattern. + DatabaseConnectionManager._(); + + /// The single, global instance of the [DatabaseConnectionManager]. + static final instance = DatabaseConnectionManager._(); + + final _log = Logger('DatabaseConnectionManager'); + + // A completer to signal when the database connection is established. + final _completer = Completer(); + + /// Returns a future that completes with the established database connection. + /// + /// If the connection has not been initialized yet, it calls `init()` to + /// establish it. Subsequent calls will return the same connection future. + Future get connection => _completer.future; + + /// Initializes the database connection. + /// + /// This method is idempotent. It parses the database URL from the + /// environment, opens a connection to the PostgreSQL server, and completes + /// the `_completer` with the connection. It only performs the connection + /// logic on the very first call. + Future init() async { + if (_completer.isCompleted) { + _log.fine('Database connection already initializing/initialized.'); + return; + } + + _log.info('Initializing database connection...'); + final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); + String? username; + String? password; + if (dbUri.userInfo.isNotEmpty) { + final parts = dbUri.userInfo.split(':'); + username = Uri.decodeComponent(parts.first); + if (parts.length > 1) { + password = Uri.decodeComponent(parts.last); + } + } + + final connection = await Connection.open( + Endpoint( + host: dbUri.host, + port: dbUri.port, + database: dbUri.path.substring(1), + username: username, + password: password, + ), + settings: const ConnectionSettings(sslMode: SslMode.require), + ); + _log.info('Database connection established successfully.'); + _completer.complete(connection); + } +} From 8f55afbd7a6e59d059c3f86b57483791b51e9997 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:39:51 +0100 Subject: [PATCH 25/57] feat(api): create singleton for app dependency management Introduces `AppDependencies`, a singleton class to manage the lifecycle of all application services and repositories. This class uses a lazy initialization pattern, creating and configuring all dependencies only on the first request. It leverages the `DatabaseConnectionManager` and includes detailed logging to trace the initialization process, ensuring a robust and performant startup sequence. --- lib/src/config/app_dependencies.dart | 195 +++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 lib/src/config/app_dependencies.dart diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart new file mode 100644 index 0000000..fbe2ac4 --- /dev/null +++ b/lib/src/config/app_dependencies.dart @@ -0,0 +1,195 @@ +import 'dart:async'; + +import 'package:ht_api/src/config/database_connection.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/services/auth_service.dart'; +import 'package:ht_api/src/services/auth_token_service.dart'; +import 'package:ht_api/src/services/dashboard_summary_service.dart'; +import 'package:ht_api/src/services/database_seeding_service.dart'; +import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; +import 'package:ht_api/src/services/jwt_auth_token_service.dart'; +import 'package:ht_api/src/services/token_blacklist_service.dart'; +import 'package:ht_api/src/services/user_preference_limit_service.dart'; +import 'package:ht_api/src/services/verification_code_storage_service.dart'; +import 'package:ht_data_client/ht_data_client.dart'; +import 'package:ht_data_postgres/ht_data_postgres.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_email_inmemory/ht_email_inmemory.dart'; +import 'package:ht_email_repository/ht_email_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:logging/logging.dart'; +import 'package:postgres/postgres.dart'; +import 'package:uuid/uuid.dart'; + +/// A singleton class to manage all application dependencies. +/// +/// This class follows a lazy initialization pattern. Dependencies are created +/// only when the `init()` method is first called, typically triggered by the +/// first incoming request. A `Completer` ensures that subsequent requests +/// await the completion of the initial setup. +class AppDependencies { + AppDependencies._(); + + /// The single, global instance of the [AppDependencies]. + static final instance = AppDependencies._(); + + final _log = Logger('AppDependencies'); + final _completer = Completer(); + + // --- Repositories --- + late final HtDataRepository headlineRepository; + late final HtDataRepository categoryRepository; + late final HtDataRepository sourceRepository; + late final HtDataRepository countryRepository; + late final HtDataRepository userRepository; + late final HtDataRepository userAppSettingsRepository; + late final HtDataRepository + userContentPreferencesRepository; + late final HtDataRepository appConfigRepository; + + // --- Services --- + late final HtEmailRepository emailRepository; + late final TokenBlacklistService tokenBlacklistService; + late final AuthTokenService authTokenService; + late final VerificationCodeStorageService verificationCodeStorageService; + late final AuthService authService; + late final DashboardSummaryService dashboardSummaryService; + late final PermissionService permissionService; + late final UserPreferenceLimitService userPreferenceLimitService; + + /// Initializes all application dependencies. + /// + /// This method is idempotent. It performs the full initialization only on + /// the first call. Subsequent calls will await the result of the first one. + Future init() { + if (_completer.isCompleted) { + _log.fine('Dependencies already initializing/initialized.'); + return _completer.future; + } + + _log.info('Initializing application dependencies...'); + _init() + .then((_) { + _log.info('Application dependencies initialized successfully.'); + _completer.complete(); + }) + .catchError((Object e, StackTrace s) { + _log.severe('Failed to initialize application dependencies.', e, s); + _completer.completeError(e, s); + }); + + return _completer.future; + } + + Future _init() async { + // 1. Establish Database Connection. + await DatabaseConnectionManager.instance.init(); + final connection = await DatabaseConnectionManager.instance.connection; + + // 2. Run Database Seeding. + final seedingService = DatabaseSeedingService( + connection: connection, + log: _log, + ); + await seedingService.createTables(); + await seedingService.seedGlobalFixtureData(); + await seedingService.seedInitialAdminAndConfig(); + + // 3. Initialize Repositories. + headlineRepository = _createRepository( + connection, + 'headlines', + Headline.fromJson, + (h) => h.toJson(), + ); + categoryRepository = _createRepository( + connection, + 'categories', + Category.fromJson, + (c) => c.toJson(), + ); + sourceRepository = _createRepository( + connection, + 'sources', + Source.fromJson, + (s) => s.toJson(), + ); + countryRepository = _createRepository( + connection, + 'countries', + Country.fromJson, + (c) => c.toJson(), + ); + userRepository = _createRepository( + connection, + 'users', + User.fromJson, + (u) => u.toJson(), + ); + userAppSettingsRepository = _createRepository( + connection, + 'user_app_settings', + UserAppSettings.fromJson, + (s) => s.toJson(), + ); + userContentPreferencesRepository = _createRepository( + connection, + 'user_content_preferences', + UserContentPreferences.fromJson, + (p) => p.toJson(), + ); + appConfigRepository = _createRepository( + connection, + 'app_config', + AppConfig.fromJson, + (c) => c.toJson(), + ); + + // 4. Initialize Services. + emailRepository = const HtEmailRepository( + emailClient: HtEmailInMemoryClient(), + ); + tokenBlacklistService = InMemoryTokenBlacklistService(); + authTokenService = JwtAuthTokenService( + userRepository: userRepository, + blacklistService: tokenBlacklistService, + uuidGenerator: const Uuid(), + ); + verificationCodeStorageService = InMemoryVerificationCodeStorageService(); + authService = AuthService( + userRepository: userRepository, + authTokenService: authTokenService, + verificationCodeStorageService: verificationCodeStorageService, + emailRepository: emailRepository, + userAppSettingsRepository: userAppSettingsRepository, + userContentPreferencesRepository: userContentPreferencesRepository, + uuidGenerator: const Uuid(), + ); + dashboardSummaryService = DashboardSummaryService( + headlineRepository: headlineRepository, + categoryRepository: categoryRepository, + sourceRepository: sourceRepository, + ); + permissionService = const PermissionService(); + userPreferenceLimitService = DefaultUserPreferenceLimitService( + appConfigRepository: appConfigRepository, + ); + } + + HtDataRepository _createRepository( + Connection connection, + String tableName, + FromJson fromJson, + ToJson toJson, + ) { + return HtDataRepository( + dataClient: HtDataPostgresClient( + connection: connection, + tableName: tableName, + fromJson: fromJson, + toJson: toJson, + log: _log, + ), + ); + } +} From 881c6b73b4b3167a852720bed32f69a37945f37c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:40:49 +0100 Subject: [PATCH 26/57] refactor(api): centralize DI in root middleware using singletons Refactors the root middleware to use the new `AppDependencies` singleton for dependency injection. - The middleware now triggers a single, idempotent `init()` call on the `AppDependencies` instance. - It then provides all the pre-initialized services and repositories from the singleton into the request context. - This simplifies the middleware, improves performance by ensuring dependencies are created only once, and creates a more robust and maintainable initialization flow. --- routes/_middleware.dart | 162 ++++++---------------------------------- 1 file changed, 23 insertions(+), 139 deletions(-) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index dcd4df6..d2bf9ad 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -1,24 +1,17 @@ import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/config/database_connection.dart'; +import 'package:ht_api/src/config/app_dependencies.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/dashboard_summary_service.dart'; -import 'package:ht_api/src/services/database_seeding_service.dart'; -import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; -import 'package:ht_api/src/services/jwt_auth_token_service.dart'; import 'package:ht_api/src/services/token_blacklist_service.dart'; import 'package:ht_api/src/services/user_preference_limit_service.dart'; import 'package:ht_api/src/services/verification_code_storage_service.dart'; -import 'package:ht_data_client/ht_data_client.dart'; -import 'package:ht_data_postgres/ht_data_postgres.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_email_inmemory/ht_email_inmemory.dart'; import 'package:ht_email_repository/ht_email_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; import 'package:uuid/uuid.dart'; // --- Request ID Wrapper --- @@ -66,24 +59,6 @@ class RequestId { // --- Middleware Definition --- final _log = Logger('RootMiddleware'); -/// Creates a data repository for a given type [T]. -HtDataRepository _createRepository({ - required Connection connection, - required String tableName, - required FromJson fromJson, - required ToJson toJson, -}) { - return HtDataRepository( - dataClient: HtDataPostgresClient( - connection: connection, - tableName: tableName, - fromJson: fromJson, - toJson: toJson, - log: _log, - ), - ); -} - Handler middleware(Handler handler) { // This is the root middleware for the entire API. It's responsible for // providing all shared dependencies to the request context. @@ -112,127 +87,36 @@ Handler middleware(Handler handler) { // dependencies for the request. .use((handler) { return (context) async { - // 1. Ensure the database connection is initialized. - await DatabaseConnectionManager.instance.init(); - final connection = await DatabaseConnectionManager.instance.connection; - - // 2. Run database seeding (idempotent). - final seedingService = DatabaseSeedingService( - connection: connection, - log: _log, - ); - await seedingService.createTables(); - await seedingService.seedGlobalFixtureData(); - await seedingService.seedInitialAdminAndConfig(); + // 1. Ensure all dependencies are initialized (idempotent). + _log.info('Ensuring all application dependencies are initialized...'); + await AppDependencies.instance.init(); + _log.info('Dependencies are ready.'); - // 3. Initialize Repositories. - final headlineRepository = _createRepository( - connection: connection, - tableName: 'headlines', - fromJson: Headline.fromJson, - toJson: (h) => h.toJson(), - ); - final categoryRepository = _createRepository( - connection: connection, - tableName: 'categories', - fromJson: Category.fromJson, - toJson: (c) => c.toJson(), - ); - final sourceRepository = _createRepository( - connection: connection, - tableName: 'sources', - fromJson: Source.fromJson, - toJson: (s) => s.toJson(), - ); - final countryRepository = _createRepository( - connection: connection, - tableName: 'countries', - fromJson: Country.fromJson, - toJson: (c) => c.toJson(), - ); - final userRepository = _createRepository( - connection: connection, - tableName: 'users', - fromJson: User.fromJson, - toJson: (u) => u.toJson(), - ); - final userAppSettingsRepository = _createRepository( - connection: connection, - tableName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (s) => s.toJson(), - ); - final userContentPreferencesRepository = - _createRepository( - connection: connection, - tableName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (p) => p.toJson(), - ); - final appConfigRepository = _createRepository( - connection: connection, - tableName: 'app_config', - fromJson: AppConfig.fromJson, - toJson: (c) => c.toJson(), - ); - - // 4. Initialize Services. - const emailRepository = HtEmailRepository( - emailClient: HtEmailInMemoryClient(), - ); - final tokenBlacklistService = InMemoryTokenBlacklistService(); - final AuthTokenService authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - uuidGenerator: const Uuid(), - ); - final verificationCodeStorageService = - InMemoryVerificationCodeStorageService(); - final authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - emailRepository: emailRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: const Uuid(), - ); - final dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); - const permissionService = PermissionService(); - final UserPreferenceLimitService userPreferenceLimitService = - DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, - ); - - // 5. Provide all dependencies to the inner handler. + // 2. Provide all dependencies to the inner handler. + final deps = AppDependencies.instance; return handler .use(provider((_) => const Uuid())) - .use(provider>((_) => headlineRepository)) - .use(provider>((_) => categoryRepository)) - .use(provider>((_) => sourceRepository)) - .use(provider>((_) => countryRepository)) - .use(provider>((_) => userRepository)) + .use(provider>((_) => deps.headlineRepository)) + .use(provider>((_) => deps.categoryRepository)) + .use(provider>((_) => deps.sourceRepository)) + .use(provider>((_) => deps.countryRepository)) + .use(provider>((_) => deps.userRepository)) .use(provider>( - (_) => userAppSettingsRepository)) + (_) => deps.userAppSettingsRepository)) .use(provider>( - (_) => userContentPreferencesRepository)) - .use(provider>((_) => appConfigRepository)) - .use(provider((_) => emailRepository)) - .use(provider((_) => tokenBlacklistService)) - .use(provider((_) => authTokenService)) + (_) => deps.userContentPreferencesRepository)) + .use(provider>((_) => deps.appConfigRepository)) + .use(provider((_) => deps.emailRepository)) + .use(provider((_) => deps.tokenBlacklistService)) + .use(provider((_) => deps.authTokenService)) .use(provider( - (_) => verificationCodeStorageService)) - .use(provider((_) => authService)) - .use(provider((_) => dashboardSummaryService)) - .use(provider((_) => permissionService)) + (_) => deps.verificationCodeStorageService)) + .use(provider((_) => deps.authService)) + .use(provider((_) => deps.dashboardSummaryService)) + .use(provider((_) => deps.permissionService)) .use(provider( - (_) => userPreferenceLimitService)) + (_) => deps.userPreferenceLimitService)) .call(context); }; }); } - From dd394d9efabd7b4e76398cb02a37ce3b97190be4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:42:04 +0100 Subject: [PATCH 27/57] refactor(api): remove obsolete server and dependency container files Deletes `lib/server.dart` and `lib/src/config/dependency_container.dart`. These files are no longer needed after refactoring the application to use a lazy-initialized, singleton-based dependency injection pattern triggered by the root middleware. This completes the transition to a more robust and standard Dart Frog architecture. --- lib/server.dart | 248 ----------------------- lib/src/config/dependency_container.dart | 122 ----------- 2 files changed, 370 deletions(-) delete mode 100644 lib/server.dart delete mode 100644 lib/src/config/dependency_container.dart diff --git a/lib/server.dart b/lib/server.dart deleted file mode 100644 index cfb14db..0000000 --- a/lib/server.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_api/src/config/dependency_container.dart'; -import 'package:ht_api/src/config/environment_config.dart'; -import 'package:ht_api/src/rbac/permission_service.dart'; -import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_api/src/services/auth_token_service.dart'; -import 'package:ht_api/src/services/dashboard_summary_service.dart'; -import 'package:ht_api/src/services/database_seeding_service.dart'; -import 'package:ht_api/src/services/default_user_preference_limit_service.dart'; -import 'package:ht_api/src/services/jwt_auth_token_service.dart'; -import 'package:ht_api/src/services/token_blacklist_service.dart'; -import 'package:ht_api/src/services/user_preference_limit_service.dart'; -import 'package:ht_api/src/services/verification_code_storage_service.dart'; -import 'package:ht_data_client/ht_data_client.dart'; -import 'package:ht_data_postgres/ht_data_postgres.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; -import 'package:ht_email_inmemory/ht_email_inmemory.dart'; -import 'package:ht_email_repository/ht_email_repository.dart'; -import 'package:ht_shared/ht_shared.dart'; -import 'package:logging/logging.dart'; -import 'package:postgres/postgres.dart'; -import 'package:uuid/uuid.dart'; - -/// Global logger instance. -final _log = Logger('ht_api'); - -/// Global PostgreSQL connection instance. -late final Connection _connection; - -/// Creates a data repository for a given type [T]. -HtDataRepository _createRepository({ - required String tableName, - required FromJson fromJson, - required ToJson toJson, -}) { - return HtDataRepository( - dataClient: HtDataPostgresClient( - connection: _connection, - tableName: tableName, - fromJson: fromJson, - toJson: toJson, - log: _log, - ), - ); -} - -/// The main entry point for the server, used by `dart_frog dev`. -/// -/// This function is responsible for the entire server startup sequence: -/// 1. **Gating Requests:** It immediately sets up a "gate" using a `Completer` -/// to hold all incoming requests until initialization is complete. -/// 2. **Async Initialization:** It performs all necessary asynchronous setup, -/// including logging, database connection, and data seeding. -/// 3. **Dependency Injection:** It initializes all repositories and services -/// and populates the `DependencyContainer`. -/// 4. **Server Start:** It starts the HTTP server with the gated handler. -/// 5. **Opening the Gate:** Once the server is listening, it completes the -/// `Completer`, allowing the gated requests to be processed. -/// 6. **Graceful Shutdown:** It sets up a listener for `SIGINT` to close -/// resources gracefully. -Future run(Handler handler, InternetAddress ip, int port) async { - final initCompleter = Completer(); - - // This middleware wraps the main handler. It awaits the completer's future, - // effectively pausing the request until `initCompleter.complete()` is called. - final gatedHandler = handler.use((innerHandler) { - return (context) async { - await initCompleter.future; - return innerHandler(context); - }; - }); - - // 1. Setup Logger - Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((record) { - // ignore: avoid_print - print( - '${record.level.name}: ${record.time}: ' - '${record.loggerName}: ${record.message}', - ); - }); - _log.info('[INIT_SEQ] 1. Logger setup complete.'); - - // 2. Establish Database Connection - _log.info('[INIT_SEQ] 2. Connecting to PostgreSQL database...'); - final dbUri = Uri.parse(EnvironmentConfig.databaseUrl); - String? username; - String? password; - if (dbUri.userInfo.isNotEmpty) { - final parts = dbUri.userInfo.split(':'); - username = Uri.decodeComponent(parts.first); - if (parts.length > 1) { - password = Uri.decodeComponent(parts.last); - } - } - - _connection = await Connection.open( - Endpoint( - host: dbUri.host, - port: dbUri.port, - database: dbUri.path.substring(1), // Remove leading '/' - username: username, - password: password, - ), - settings: const ConnectionSettings(sslMode: SslMode.require), - ); - _log.info('[INIT_SEQ] 2. PostgreSQL database connection established.'); - - // 3. Initialize and run database seeding - _log.info('[INIT_SEQ] 3. Initializing database seeding service...'); - final seedingService = DatabaseSeedingService( - connection: _connection, - log: _log, - ); - _log.info('[INIT_SEQ] 3. Creating tables if they do not exist...'); - await seedingService.createTables(); - _log.info('[INIT_SEQ] 3. Seeding global fixture data...'); - await seedingService.seedGlobalFixtureData(); - _log.info('[INIT_SEQ] 3. Seeding initial admin and config...'); - await seedingService.seedInitialAdminAndConfig(); - _log - ..info('[INIT_SEQ] 3. Database seeding complete.') - // 4. Initialize Repositories - ..info('[INIT_SEQ] 4. Initializing data repositories...'); - final headlineRepository = _createRepository( - tableName: 'headlines', - fromJson: Headline.fromJson, - toJson: (h) => h.toJson(), - ); - final categoryRepository = _createRepository( - tableName: 'categories', - fromJson: Category.fromJson, - toJson: (c) => c.toJson(), - ); - final sourceRepository = _createRepository( - tableName: 'sources', - fromJson: Source.fromJson, - toJson: (s) => s.toJson(), - ); - final countryRepository = _createRepository( - tableName: 'countries', - fromJson: Country.fromJson, - toJson: (c) => c.toJson(), - ); - final userRepository = _createRepository( - tableName: 'users', - fromJson: User.fromJson, - toJson: (u) => u.toJson(), - ); - final userAppSettingsRepository = _createRepository( - tableName: 'user_app_settings', - fromJson: UserAppSettings.fromJson, - toJson: (s) => s.toJson(), - ); - final userContentPreferencesRepository = - _createRepository( - tableName: 'user_content_preferences', - fromJson: UserContentPreferences.fromJson, - toJson: (p) => p.toJson(), - ); - final appConfigRepository = _createRepository( - tableName: 'app_config', - fromJson: AppConfig.fromJson, - toJson: (c) => c.toJson(), - ); - _log - ..info('[INIT_SEQ] 4. Data repositories initialized.') - // 5. Initialize Services - ..info('[INIT_SEQ] 5. Initializing services...'); - const emailRepository = HtEmailRepository( - emailClient: HtEmailInMemoryClient(), - ); - final tokenBlacklistService = InMemoryTokenBlacklistService(); - final AuthTokenService authTokenService = JwtAuthTokenService( - userRepository: userRepository, - blacklistService: tokenBlacklistService, - uuidGenerator: const Uuid(), - ); - final verificationCodeStorageService = - InMemoryVerificationCodeStorageService(); - final authService = AuthService( - userRepository: userRepository, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - emailRepository: emailRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - uuidGenerator: const Uuid(), - ); - final dashboardSummaryService = DashboardSummaryService( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - ); - const permissionService = PermissionService(); - final UserPreferenceLimitService userPreferenceLimitService = - DefaultUserPreferenceLimitService( - appConfigRepository: appConfigRepository, - ); - _log - ..info('[INIT_SEQ] 5. Services initialized.') - // 6. Populate the DependencyContainer - ..info('[INIT_SEQ] 6. Populating dependency container...'); - DependencyContainer.instance.init( - headlineRepository: headlineRepository, - categoryRepository: categoryRepository, - sourceRepository: sourceRepository, - countryRepository: countryRepository, - userRepository: userRepository, - userAppSettingsRepository: userAppSettingsRepository, - userContentPreferencesRepository: userContentPreferencesRepository, - appConfigRepository: appConfigRepository, - emailRepository: emailRepository, - tokenBlacklistService: tokenBlacklistService, - authTokenService: authTokenService, - verificationCodeStorageService: verificationCodeStorageService, - authService: authService, - dashboardSummaryService: dashboardSummaryService, - permissionService: permissionService, - userPreferenceLimitService: userPreferenceLimitService, - ); - - // 7. Start the server with the gated handler - _log.info('[INIT_SEQ] 7. Starting server with gated handler...'); - final server = await serve(gatedHandler, ip, port); - _log.info('[INIT_SEQ] 7. Server listening on port ${server.port}'); - - // 8. Open the gate now that the server is ready. - _log.info('[INIT_SEQ] 8. Server ready. Opening request gate.'); - initCompleter.complete(); - - // 9. Handle graceful shutdown - _log.info('[INIT_SEQ] 9. Registering graceful shutdown handler.'); - ProcessSignal.sigint.watch().listen((_) async { - _log.info('[SHUTDOWN] Received SIGINT. Shutting down...'); - await _connection.close(); - _log.info('[SHUTDOWN] Database connection closed.'); - await server.close(force: true); - _log.info('[SHUTDOWN] Server shut down.'); - exit(0); - }); - _log.info('[INIT_SEQ] Server initialization sequence complete.'); - - return server; -} diff --git a/lib/src/config/dependency_container.dart b/lib/src/config/dependency_container.dart deleted file mode 100644 index f656e18..0000000 --- a/lib/src/config/dependency_container.dart +++ /dev/null @@ -1,122 +0,0 @@ -// ignore_for_file: public_member_api_docs - -import 'package:ht_api/src/rbac/permission_service.dart'; -import 'package:ht_api/src/services/auth_service.dart'; -import 'package:ht_api/src/services/auth_token_service.dart'; -import 'package:ht_api/src/services/dashboard_summary_service.dart'; -import 'package:ht_api/src/services/token_blacklist_service.dart'; -import 'package:ht_api/src/services/user_preference_limit_service.dart'; -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'; - -/// {@template dependency_container} -/// A singleton service locator for managing and providing access to all shared -/// application dependencies (repositories and services). -/// -/// **Rationale for this pattern in Dart Frog:** -/// -/// In Dart Frog, middleware defined in the `routes` directory is composed and -/// initialized *before* the custom `run` function in `lib/src/config/server.dart` -/// is executed. This creates a lifecycle challenge: if you try to provide -/// dependencies using `handler.use(provider<...>)` inside `server.dart`, any -/// middleware from the `routes` directory that needs those dependencies will -/// fail because it runs too early. -/// -/// This `DependencyContainer` solves the problem by acting as a centralized, -/// globally accessible holder for all dependencies. -/// -/// **The Dependency Injection Flow:** -/// -/// 1. **Initialization (`server.dart`):** The `run` function in `server.dart` -/// initializes all repositories and services. -/// 2. **Population (`server.dart`):** It then calls `DependencyContainer.instance.init(...)` -/// to populate this singleton with the initialized instances. This happens -/// only once at server startup. -/// 3. **Provision (`routes/_middleware.dart`):** The root middleware, which -/// runs for every request, accesses the initialized dependencies from -/// `DependencyContainer.instance` and uses `context.provide()` to -/// inject them into the request's context. -/// 4. **Consumption (Other Middleware/Routes):** All subsequent middleware -/// (like the authentication middleware) and route handlers can now safely -/// read the dependencies from the context using `context.read()`. -/// -/// This pattern ensures that dependencies are created once and are available -/// throughout the entire request lifecycle, respecting Dart Frog's execution -/// order. -/// {@endtemplate} -class DependencyContainer { - // Private constructor for the singleton pattern. - DependencyContainer._(); - - /// The single, global instance of the [DependencyContainer]. - static final instance = DependencyContainer._(); - - final _log = Logger('DependencyContainer'); - - // --- Repositories --- - late final HtDataRepository headlineRepository; - late final HtDataRepository categoryRepository; - late final HtDataRepository sourceRepository; - late final HtDataRepository countryRepository; - late final HtDataRepository userRepository; - late final HtDataRepository userAppSettingsRepository; - late final HtDataRepository - userContentPreferencesRepository; - late final HtDataRepository appConfigRepository; - late final HtEmailRepository emailRepository; - - // --- Services --- - late final TokenBlacklistService tokenBlacklistService; - late final AuthTokenService authTokenService; - late final VerificationCodeStorageService verificationCodeStorageService; - late final AuthService authService; - late final DashboardSummaryService dashboardSummaryService; - late final PermissionService permissionService; - late final UserPreferenceLimitService userPreferenceLimitService; - - /// Initializes the container with all the required dependencies. - /// - /// This method must be called exactly once at server startup from within - /// the `run` function in `server.dart`. - void init({ - required HtDataRepository headlineRepository, - required HtDataRepository categoryRepository, - required HtDataRepository sourceRepository, - required HtDataRepository countryRepository, - required HtDataRepository userRepository, - required HtDataRepository userAppSettingsRepository, - required HtDataRepository - userContentPreferencesRepository, - required HtDataRepository appConfigRepository, - required HtEmailRepository emailRepository, - required TokenBlacklistService tokenBlacklistService, - required AuthTokenService authTokenService, - required VerificationCodeStorageService verificationCodeStorageService, - required AuthService authService, - required DashboardSummaryService dashboardSummaryService, - required PermissionService permissionService, - required UserPreferenceLimitService userPreferenceLimitService, - }) { - this.headlineRepository = headlineRepository; - this.categoryRepository = categoryRepository; - this.sourceRepository = sourceRepository; - this.countryRepository = countryRepository; - this.userRepository = userRepository; - this.userAppSettingsRepository = userAppSettingsRepository; - this.userContentPreferencesRepository = userContentPreferencesRepository; - this.appConfigRepository = appConfigRepository; - this.emailRepository = emailRepository; - this.tokenBlacklistService = tokenBlacklistService; - this.authTokenService = authTokenService; - this.verificationCodeStorageService = verificationCodeStorageService; - this.authService = authService; - this.dashboardSummaryService = dashboardSummaryService; - this.permissionService = permissionService; - this.userPreferenceLimitService = userPreferenceLimitService; - - _log.info('[INIT_SEQ] 6. Dependency container populated successfully.'); - } -} From f769493c068a1233b61890dc74c5a5586c0058fc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:50:22 +0100 Subject: [PATCH 28/57] fix(api): restore root logger configuration in middleware Re-introduces the root logger configuration, which was lost during the refactor away from `lib/server.dart`. The configuration is now placed in the root middleware and uses a flag to ensure it only runs once per application lifecycle. This is critical for enabling log output for all subsequent processes, including dependency initialization and error handling. --- routes/_middleware.dart | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index d2bf9ad..e8ab2c7 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -59,11 +59,29 @@ class RequestId { // --- Middleware Definition --- final _log = Logger('RootMiddleware'); +// A flag to ensure the logger is only configured once for the application's +// entire lifecycle. +bool _loggerConfigured = false; + Handler middleware(Handler handler) { // This is the root middleware for the entire API. It's responsible for // providing all shared dependencies to the request context. // The order of `.use()` calls is important: the last one in the chain // runs first. + + // This check ensures that the logger is configured only once. + if (!_loggerConfigured) { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print( + '${record.level.name}: ${record.time}: ${record.loggerName}: ' + '${record.message}', + ); + }); + _loggerConfigured = true; + } + return handler // --- Core Middleware --- // These run after all dependencies have been provided. From a8eff434cd4f2cd6bd8a88ef2a792cb0ebc3a3fd Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 19:51:12 +0100 Subject: [PATCH 29/57] feat(api): add diagnostic logging for environment variables Enhances `EnvironmentConfig` to log all available environment variables if `DATABASE_URL` is not found. This provides crucial diagnostic information to debug configuration issues where environment variables are not being loaded as expected. --- lib/src/config/environment_config.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index a7172fc..1050857 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:logging/logging.dart'; /// {@template environment_config} /// A utility class for accessing environment variables. @@ -9,6 +10,7 @@ import 'dart:io'; /// {@endtemplate} abstract final class EnvironmentConfig { /// Retrieves the PostgreSQL database connection URI from the environment. + static final _log = Logger('EnvironmentConfig'); /// /// The value is read from the `DATABASE_URL` environment variable. /// @@ -17,6 +19,12 @@ abstract final class EnvironmentConfig { static String get databaseUrl { final dbUrl = Platform.environment['DATABASE_URL']; if (dbUrl == null || dbUrl.isEmpty) { + _log.severe( + 'DATABASE_URL not found. Dumping available environment variables:', + ); + Platform.environment.forEach((key, value) { + _log.severe(' - $key: $value'); + }); throw StateError( 'FATAL: DATABASE_URL environment variable is not set. ' 'The application cannot start without a database connection.', From 2ecb651c986f297e4bf723c2377c37ee5b413814 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:40:49 +0100 Subject: [PATCH 30/57] feat(api): add dotenv dependency Adds the `dotenv` package to manage environment variables directly within the application. This makes the server's configuration self-contained and less reliant on the execution environment of the `dart_frog_cli` tool. --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index b1f598c..56fade7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: dart_frog: ^1.1.0 dart_jsonwebtoken: ^3.2.0 + dotenv: ^4.2.0 ht_data_client: git: url: https://github.com/headlines-toolkit/ht-data-client.git From cd940ef189f54c0089b0b334d5f906d960ed270c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:41:46 +0100 Subject: [PATCH 31/57] feat(api): load .env file directly within app dependencies Modifies the `AppDependencies` singleton to use the `dotenv` package to load environment variables from the `.env` file at the start of its initialization sequence. This makes the application's configuration self-contained and resilient, removing the reliance on the `dart_frog_cli` to manage the environment. --- lib/src/config/app_dependencies.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index fbe2ac4..1047377 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:dotenv/dotenv.dart' as dotenv; import 'package:ht_api/src/config/database_connection.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; @@ -82,6 +83,10 @@ class AppDependencies { } Future _init() async { + // 0. Load environment variables from .env file. + dotenv.load(); + _log.info('Environment variables loaded from .env file.'); + // 1. Establish Database Connection. await DatabaseConnectionManager.instance.init(); final connection = await DatabaseConnectionManager.instance.connection; From bd1f880167658927a4600419ed2401af42d6ad26 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:44:33 +0100 Subject: [PATCH 32/57] fix(api): correct dotenv initialization and usage Updates the `dotenv` usage to correctly instantiate the `DotEnv` class and call the `load()` method on the instance, as per the package's documentation. This ensures that environment variables from the .env file are properly loaded into the application's environment. --- lib/src/config/app_dependencies.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 1047377..e22463e 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:dotenv/dotenv.dart' as dotenv; +import 'package:dotenv/dotenv.dart'; import 'package:ht_api/src/config/database_connection.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; @@ -84,7 +84,7 @@ class AppDependencies { Future _init() async { // 0. Load environment variables from .env file. - dotenv.load(); + DotEnv(includePlatformEnvironment: true).load(); _log.info('Environment variables loaded from .env file.'); // 1. Establish Database Connection. From 87786c2a5c9026246307749349a0908454cf085c Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:47:36 +0100 Subject: [PATCH 33/57] fix(api): centralize and correct dotenv loading Refactors environment variable handling to be self-contained within the `EnvironmentConfig` class. - The `dotenv` loading logic is moved from `AppDependencies` to `EnvironmentConfig`. - `EnvironmentConfig` now creates a static `DotEnv` instance, loads the `.env` file, and all getters now read from this instance instead of `Platform.environment`. - This ensures that environment variables are loaded and read correctly from a single, reliable source, resolving the issue where variables were not available to the application. --- lib/src/config/app_dependencies.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index e22463e..fbe2ac4 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dotenv/dotenv.dart'; import 'package:ht_api/src/config/database_connection.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; import 'package:ht_api/src/services/auth_service.dart'; @@ -83,10 +82,6 @@ class AppDependencies { } Future _init() async { - // 0. Load environment variables from .env file. - DotEnv(includePlatformEnvironment: true).load(); - _log.info('Environment variables loaded from .env file.'); - // 1. Establish Database Connection. await DatabaseConnectionManager.instance.init(); final connection = await DatabaseConnectionManager.instance.connection; From c2d52a573de428dbe042001c30f1a95e2ffe76a8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:50:28 +0100 Subject: [PATCH 34/57] fix(api): centralize and correct dotenv loading in EnvironmentConfig Refactors environment variable handling to be self-contained within the `EnvironmentConfig` class. - `EnvironmentConfig` now creates a static `DotEnv` instance, loads the `.env` file, and all getters now read from this instance instead of `Platform.environment`. - This ensures that environment variables are loaded and read correctly from a single, reliable source, resolving the issue where variables were not available to the application. --- lib/src/config/environment_config.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/src/config/environment_config.dart b/lib/src/config/environment_config.dart index 1050857..5570124 100644 --- a/lib/src/config/environment_config.dart +++ b/lib/src/config/environment_config.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:logging/logging.dart'; +import 'package:dotenv/dotenv.dart'; /// {@template environment_config} /// A utility class for accessing environment variables. @@ -9,20 +10,25 @@ import 'package:logging/logging.dart'; /// connection strings are managed outside of the source code. /// {@endtemplate} abstract final class EnvironmentConfig { - /// Retrieves the PostgreSQL database connection URI from the environment. static final _log = Logger('EnvironmentConfig'); + + // The DotEnv instance that loads the .env file and platform variables. + // It's initialized once and reused. + static final _env = DotEnv(includePlatformEnvironment: true)..load(); + + /// Retrieves the PostgreSQL database connection URI from the environment. /// /// The value is read from the `DATABASE_URL` environment variable. /// /// Throws a [StateError] if the `DATABASE_URL` environment variable is not /// set, as the application cannot function without it. static String get databaseUrl { - final dbUrl = Platform.environment['DATABASE_URL']; + final dbUrl = _env['DATABASE_URL']; if (dbUrl == null || dbUrl.isEmpty) { _log.severe( 'DATABASE_URL not found. Dumping available environment variables:', ); - Platform.environment.forEach((key, value) { + _env.map.forEach((key, value) { _log.severe(' - $key: $value'); }); throw StateError( @@ -37,5 +43,5 @@ abstract final class EnvironmentConfig { /// /// The value is read from the `ENV` environment variable. /// Defaults to 'production' if the variable is not set. - static String get environment => Platform.environment['ENV'] ?? 'production'; + static String get environment => _env['ENV'] ?? 'production'; } From 3704ddaf37f9b6ed895e8b9e21be6b335a8a7e72 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 20:56:20 +0100 Subject: [PATCH 35/57] fix(api): align categories schema and seeding with data model - Updates the `CREATE TABLE` statement for the `categories` table to include `slug`, `description`, `status`, and `type` columns. - Updates the corresponding `INSERT` statement in the seeding logic to populate all fields from the `Category` model. - Changes the `ON CONFLICT` clause to use the `slug` as the unique business key for idempotency. This resolves the "superfluous variables" error during database seeding. --- lib/src/services/database_seeding_service.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index ab6080d..8109d59 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -59,7 +59,11 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + status TEXT, + type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -160,8 +164,10 @@ class DatabaseSeedingService { final category = Category.fromJson(data); await _connection.execute( Sql.named( - 'INSERT INTO categories (id, name) VALUES (@id, @name) ' - 'ON CONFLICT (id) DO NOTHING', + 'INSERT INTO categories (id, name, slug, description, status, ' + 'type, created_at, updated_at) VALUES (@id, @name, @slug, ' + '@description, @status, @type, @created_at, @updated_at) ' + 'ON CONFLICT (slug) DO NOTHING', ), parameters: category.toJson(), ); From f2af724a2cec2fc0932cb274c529d4eedf26ba10 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:10:21 +0100 Subject: [PATCH 36/57] refactor(api): remove slug from categories table and seeder The `slug` field is not used by the client applications and was causing a mismatch between the `Category` data model and the database schema, leading to a server crash on startup. This change removes the `slug` column from the `categories` table definition and updates the database seeder to no longer attempt to insert a slug. The `ON CONFLICT` clause for seeding has been updated to use the primary key `id` to ensure idempotency. --- lib/src/services/database_seeding_service.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 8109d59..742b6f9 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -60,7 +60,6 @@ class DatabaseSeedingService { CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - slug TEXT NOT NULL UNIQUE, description TEXT, status TEXT, type TEXT, @@ -164,10 +163,10 @@ class DatabaseSeedingService { final category = Category.fromJson(data); await _connection.execute( Sql.named( - 'INSERT INTO categories (id, name, slug, description, status, ' - 'type, created_at, updated_at) VALUES (@id, @name, @slug, ' + 'INSERT INTO categories (id, name, description, status, ' + 'type, created_at, updated_at) VALUES (@id, @name, ' '@description, @status, @type, @created_at, @updated_at) ' - 'ON CONFLICT (slug) DO NOTHING', + 'ON CONFLICT (id) DO NOTHING', ), parameters: category.toJson(), ); From 535178785303f8a120f110727ee87fa1b0c42e29 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:17:33 +0100 Subject: [PATCH 37/57] fix(api): align categories table schema with model The `categories` table schema in the database seeder was missing the `icon_url` column, which is present in the `Category` data model. This caused a mismatch between the model and the database layer. This change adds the `icon_url` column to the `CREATE TABLE` and `INSERT` statements in the `DatabaseSeedingService`, ensuring the schema is fully synchronized with the model. --- lib/src/services/database_seeding_service.dart | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 742b6f9..4d9d32d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -61,6 +61,7 @@ class DatabaseSeedingService { id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, + icon_url TEXT, status TEXT, type TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -163,9 +164,9 @@ class DatabaseSeedingService { final category = Category.fromJson(data); await _connection.execute( Sql.named( - 'INSERT INTO categories (id, name, description, status, ' - 'type, created_at, updated_at) VALUES (@id, @name, ' - '@description, @status, @type, @created_at, @updated_at) ' + 'INSERT INTO categories (id, name, description, icon_url, ' + 'status, type, created_at, updated_at) VALUES (@id, @name, ' + '@description, @iconUrl, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: category.toJson(), From 39e4387eee7c0474e9353cf21584fb9cc37774df Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:25:19 +0100 Subject: [PATCH 38/57] 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) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: category.toJson(), Sql.named( 'INSERT INTO headlines (id, title, source_id, category_id, ' 'image_url, url, published_at, description, content) ' 'VALUES (@id, @title, @source_id, @category_id, @image_url, @url, ' '@published_at, @description, @content) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: headline.toJson(), --- lib/src/services/database_seeding_service.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 4d9d32d..abdb875 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -165,8 +165,8 @@ class DatabaseSeedingService { await _connection.execute( Sql.named( 'INSERT INTO categories (id, name, description, icon_url, ' - 'status, type, created_at, updated_at) VALUES (@id, @name, ' - '@description, @iconUrl, @status, @type, @created_at, @updated_at) ' + 'status, type, created_at, updated_at) VALUES (@id, @name, @description, ' + '@icon_url, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: category.toJson(), @@ -207,8 +207,8 @@ class DatabaseSeedingService { Sql.named( 'INSERT INTO headlines (id, title, source_id, category_id, ' 'image_url, url, published_at, description, content) ' - 'VALUES (@id, @title, @sourceId, @categoryId, @imageUrl, @url, ' - '@publishedAt, @description, @content) ' + 'VALUES (@id, @title, @source_id, @category_id, @image_url, @url, ' + '@published_at, @description, @content) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: headline.toJson(), From c5a505ae870bbbf23308df6620d774c02db8ceb0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:30:30 +0100 Subject: [PATCH 39/57] fix(api): make database seeder resilient to missing optional fields The database seeding process was crashing with a "Missing variable" error. This was caused by a conflict between the models' `toJson` method (which uses `includeIfNull: false` and omits keys for null values) and the `postgres` driver's requirement that all named SQL parameters exist in the provided parameters map. This change refactors the seeding logic to manually ensure that parameter maps for `categories` and `headlines` always contain keys for all optional/nullable columns (e.g., `description`, `icon_url`, `content`), setting their value to `null` if they are not already present. This makes the seeding process robust against incomplete fixture data and permanently resolves the startup error. --- .../services/database_seeding_service.dart | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index abdb875..c5edf55 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -162,6 +162,16 @@ class DatabaseSeedingService { _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); + await _connection.execute( Sql.named( 'INSERT INTO categories (id, name, description, icon_url, ' @@ -169,7 +179,7 @@ class DatabaseSeedingService { '@icon_url, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: category.toJson(), + parameters: params, ); } @@ -203,6 +213,13 @@ class DatabaseSeedingService { _log.fine('Seeding headlines...'); for (final data in headlinesFixturesData) { final headline = Headline.fromJson(data); + final params = headline.toJson(); + + // Ensure optional fields exist for the postgres driver. + params.putIfAbsent('description', () => null); + params.putIfAbsent('content', () => null); + params.putIfAbsent('updated_at', () => null); + await _connection.execute( Sql.named( 'INSERT INTO headlines (id, title, source_id, category_id, ' @@ -211,7 +228,7 @@ class DatabaseSeedingService { '@published_at, @description, @content) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: headline.toJson(), + parameters: params, ); } From 2ed4fbf9b6cef1745f8d20d3cd7dbab5f4c0fd0d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:39:47 +0100 Subject: [PATCH 40/57] feat(api): align sources table schema with data model The `sources` table in the database was overly simplistic and did not reflect the rich `Source` data model, leading to data loss and seeding errors. This change expands the `sources` table schema to include all fields from the `Source` model, such as `description`, `url`, and `status`. It correctly handles nested objects by storing `source_type` as `JSONB` and creating a foreign key relationship for `headquarters` to the `countries` table. The seeding logic has been updated to populate these new columns, resolving the architectural mismatch. --- .../services/database_seeding_service.dart | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index c5edf55..9a19f86 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -73,7 +73,14 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS sources ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + url TEXT, + language TEXT, + status TEXT, + type TEXT, + source_type JSONB, + headquarters_country_id TEXT REFERENCES countries(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -187,12 +194,31 @@ class DatabaseSeedingService { _log.fine('Seeding sources...'); for (final data in sourcesFixturesData) { final source = Source.fromJson(data); + final params = source.toJson(); + + // The `headquarters` field in the model is a nested `Country` object. + // We store its ID in the `headquarters_country_id` column. + params['headquarters_country_id'] = source.headquarters?.id; + + // 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); + await _connection.execute( Sql.named( - 'INSERT INTO sources (id, name) VALUES (@id, @name) ' + '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, ' + '@headquarters_country_id, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: source.toJson(), + parameters: params, ); } @@ -259,14 +285,20 @@ class DatabaseSeedingService { try { // Seed AppConfig _log.fine('Seeding AppConfig...'); - final appConfig = AppConfig.fromJson(appConfigFixtureData); + 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. await _connection.execute( Sql.named( 'INSERT INTO app_config (id, user_preference_limits) ' 'VALUES (@id, @user_preference_limits) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: appConfig.toJson(), + parameters: { + 'id': appConfig.id, + 'user_preference_limits': appConfig.userPreferenceLimits.toJson(), + }, ); // Seed Admin User @@ -276,13 +308,19 @@ class DatabaseSeedingService { (user) => user.roles.contains(UserRoles.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. await _connection.execute( Sql.named( 'INSERT INTO users (id, email, roles) ' 'VALUES (@id, @email, @roles) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: adminUser.toJson(), + parameters: { + 'id': adminUser.id, + 'email': adminUser.email, + 'roles': adminUser.roles, + }, ); // Seed default settings and preferences for the admin user. @@ -296,7 +334,12 @@ class DatabaseSeedingService { 'VALUES (@id, @user_id, @display_settings, @language) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: {...adminSettings.toJson(), 'user_id': adminUser.id}, + parameters: { + 'id': adminSettings.id, + 'user_id': adminUser.id, + 'display_settings': adminSettings.displaySettings.toJson(), + 'language': adminSettings.language.toJson(), + }, ); await _connection.execute( @@ -307,7 +350,14 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: {...adminPreferences.toJson(), 'user_id': adminUser.id}, + parameters: { + 'id': adminPreferences.id, + 'user_id': adminUser.id, + 'followed_categories': adminPreferences.followedCategories, + 'followed_sources': adminPreferences.followedSources, + 'followed_countries': adminPreferences.followedCountries, + 'saved_headlines': adminPreferences.savedHeadlines, + }, ); await _connection.execute('COMMIT'); From 1ddc3a4137e28d3f0a94d3db1f78c3acf8e0e2b2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:42:56 +0100 Subject: [PATCH 41/57] fix(api): align user_app_settings schema with model The `user_app_settings` table incorrectly defined the `language` column as `JSONB`, while the `UserAppSettings` model correctly defines it as a `String`. This caused a crash during database seeding when trying to call `.toJson()` on a String. This change corrects the `language` column type to `TEXT` in the schema and updates the seeding logic to pass the language as a simple string, aligning the database with the data model and resolving the startup error. --- lib/src/services/database_seeding_service.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 9a19f86..72ec21c 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -119,8 +119,8 @@ class DatabaseSeedingService { CREATE TABLE IF NOT EXISTS user_app_settings ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - display_settings JSONB NOT NULL, - language JSONB NOT NULL, + display_settings JSONB NOT NULL, -- Nested object, stored as JSON + language TEXT NOT NULL, -- Simple string, stored as TEXT created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ ); @@ -337,8 +337,10 @@ class DatabaseSeedingService { parameters: { 'id': adminSettings.id, 'user_id': adminUser.id, - 'display_settings': adminSettings.displaySettings.toJson(), - 'language': adminSettings.language.toJson(), + 'display_settings': + adminSettings.displaySettings.toJson(), // This is a complex object + 'language': + adminSettings.language, // This is a simple String }, ); From 7369603a946c5cccba5d6be9e10637f77783a330 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:46:16 +0100 Subject: [PATCH 42/57] fix(api): remove superfluous headquarters object from source seeder params The `sources` seeder was failing with a "superfluous variables" error for the `headquarters` key. The logic correctly added the `headquarters_country_id` to the parameter map but failed to remove the original nested `headquarters` object. This change updates the seeder to remove the `headquarters` key from the map after its ID has been extracted, ensuring the parameter map exactly matches the SQL query and resolving the startup error. --- lib/src/services/database_seeding_service.dart | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 72ec21c..2117b63 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -196,9 +196,13 @@ class DatabaseSeedingService { final source = Source.fromJson(data); final params = source.toJson(); - // The `headquarters` field in the model is a nested `Country` object. - // We store its ID in the `headquarters_country_id` column. + // 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); From 246f8b0a524a08a92002a8d41df5347d26397421 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:48:55 +0100 Subject: [PATCH 43/57] fix(api): correct source_type column to TEXT in sources schema The database seeding was failing with a JSON syntax error because the `sources` table defined the `source_type` column as `JSONB`, but the `Source` model provides this value as a simple string (from an enum). This change corrects the `source_type` column type to `TEXT`, aligning the database schema with the data model and resolving the final seeding error. --- lib/src/services/database_seeding_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 2117b63..83caa9b 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -79,7 +79,7 @@ class DatabaseSeedingService { language TEXT, status TEXT, type TEXT, - source_type JSONB, + source_type TEXT, headquarters_country_id TEXT REFERENCES countries(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ From 3dc110950af111d7d4ea194dee6046acdf888913 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:51:25 +0100 Subject: [PATCH 44/57] fix(api): correct seeding order to respect foreign key constraints The database seeding was failing with a foreign key constraint violation because it attempted to seed `sources` before `countries`. The `sources` table has a foreign key dependency on the `countries` table. This change reorders the operations in `seedGlobalFixtureData`, ensuring that `countries` are seeded before `sources`, which resolves the error. --- .../services/database_seeding_service.dart | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 83caa9b..1bdcc4f 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -190,6 +190,19 @@ 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); + await _connection.execute( + Sql.named( + 'INSERT INTO countries (id, name, code) ' + 'VALUES (@id, @name, @code) ON CONFLICT (id) DO NOTHING', + ), + parameters: country.toJson(), + ); + } + // Seed Sources _log.fine('Seeding sources...'); for (final data in sourcesFixturesData) { @@ -226,19 +239,6 @@ class DatabaseSeedingService { ); } - // Seed Countries - _log.fine('Seeding countries...'); - for (final data in countriesFixturesData) { - final country = Country.fromJson(data); - await _connection.execute( - Sql.named( - 'INSERT INTO countries (id, name, code) ' - 'VALUES (@id, @name, @code) ON CONFLICT (id) DO NOTHING', - ), - parameters: country.toJson(), - ); - } - // Seed Headlines _log.fine('Seeding headlines...'); for (final data in headlinesFixturesData) { From 3b11a62507d1592262b708c807b4e0d0667dc65e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 21:57:52 +0100 Subject: [PATCH 45/57] fix(api): align countries table schema with data model The `countries` table schema was not synchronized with the `Country` model, using an incorrect `code` column and missing `flag_url`, `status`, and `type`. This caused a crash during database seeding. This change updates the `countries` table schema and its corresponding seeding logic to perfectly mirror the `Country` model. This resolves the final schema mismatch and allows the server to start successfully. --- .../services/database_seeding_service.dart | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 1bdcc4f..99089a9 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -90,8 +90,11 @@ class DatabaseSeedingService { await _connection.execute(''' CREATE TABLE IF NOT EXISTS countries ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + 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 ); @@ -194,12 +197,19 @@ class DatabaseSeedingService { _log.fine('Seeding countries...'); for (final data in countriesFixturesData) { final country = Country.fromJson(data); + final params = country.toJson(); + + // Ensure optional fields exist for the postgres driver. + params.putIfAbsent('updated_at', () => null); + await _connection.execute( Sql.named( - 'INSERT INTO countries (id, name, code) ' - 'VALUES (@id, @name, @code) ON CONFLICT (id) DO NOTHING', + '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) ' + 'ON CONFLICT (id) DO NOTHING', ), - parameters: country.toJson(), + parameters: params, ); } From 4e9f3ceac2d684e582aa39c8f157a69fed3f8cd8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:01:30 +0100 Subject: [PATCH 46/57] fix(api): make headline seeder resilient and align with schema The database seeding was crashing with a "Missing variable for 'source_id'" error due to invalid fixture data. Additionally, the `INSERT` statement for headlines was missing the `created_at` and `updated_at` columns. This change makes the headline seeder more robust by validating that required fields (`source_id`, `category_id`) are present before attempting insertion, skipping invalid fixtures with a warning. It also updates the `INSERT` statement to be fully aligned with the table schema, resolving all remaining seeding issues. --- lib/src/services/database_seeding_service.dart | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 99089a9..1de35f7 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -255,6 +255,17 @@ class DatabaseSeedingService { final headline = Headline.fromJson(data); final params = headline.toJson(); + // The `source_id` and `category_id` columns are NOT NULL. If a + // fixture is missing these, the `toJson()` map will lack the key + // and cause a crash. We log a warning and skip the invalid entry. + if (params['source_id'] == null || params['category_id'] == null) { + _log.warning( + 'Skipping headline fixture with missing source or category ID: ' + '${headline.title}', + ); + continue; + } + // Ensure optional fields exist for the postgres driver. params.putIfAbsent('description', () => null); params.putIfAbsent('content', () => null); @@ -262,10 +273,10 @@ class DatabaseSeedingService { await _connection.execute( Sql.named( - 'INSERT INTO headlines (id, title, source_id, category_id, ' - 'image_url, url, published_at, description, content) ' + 'INSERT INTO headlines (id, title, source_id, category_id, image_url, ' + 'url, published_at, description, content, created_at, updated_at) ' 'VALUES (@id, @title, @source_id, @category_id, @image_url, @url, ' - '@published_at, @description, @content) ' + '@published_at, @description, @content, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, From 0a777a63334e1aab17eb6873a3502ce8629227b8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:04:18 +0100 Subject: [PATCH 47/57] fix(api): align headlines schema and seeder with data model The `headlines` table schema was missing the `status` and `type` columns inherited from its `FeedItem` base class. Additionally, the seeding logic was not correctly handling the nested `source` and `category` objects present in the `Headline` model, leading to a crash. This change updates the `headlines` table schema to be a complete representation of the model. The seeding logic is refactored to correctly extract foreign key IDs from the nested objects and to ensure the parameter map sent to the database driver is perfectly aligned with the schema. This resolves the final seeding error. --- .../services/database_seeding_service.dart | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 1de35f7..1038cfa 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -114,6 +114,8 @@ class DatabaseSeedingService { content TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ + status TEXT, + type TEXT ); '''); @@ -254,29 +256,35 @@ class DatabaseSeedingService { for (final data in headlinesFixturesData) { final headline = Headline.fromJson(data); final params = headline.toJson(); - - // The `source_id` and `category_id` columns are NOT NULL. If a - // fixture is missing these, the `toJson()` map will lack the key - // and cause a crash. We log a warning and skip the invalid entry. - if (params['source_id'] == null || params['category_id'] == null) { + + // The `source_id` and `category_id` columns are NOT NULL. If a fixture + // is missing the nested source or category object, we cannot proceed. + if (headline.source == null || headline.category == null) { _log.warning( 'Skipping headline fixture with missing source or category ID: ' '${headline.title}', ); continue; } - + + // Extract IDs from nested objects and remove the objects to match 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('content', () => null); params.putIfAbsent('updated_at', () => null); - + await _connection.execute( Sql.named( - 'INSERT INTO headlines (id, title, source_id, category_id, image_url, ' - 'url, published_at, description, content, created_at, updated_at) ' - 'VALUES (@id, @title, @source_id, @category_id, @image_url, @url, ' - '@published_at, @description, @content, @created_at, @updated_at) ' + 'INSERT INTO headlines (id, title, source_id, category_id, ' + 'image_url, url, published_at, description, content, status, ' + 'type, created_at, updated_at) VALUES (@id, @title, @source_id, ' + '@category_id, @image_url, @url, @published_at, @description, ' + '@content, @status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, From 970fced9af5101f42b0ff3b32bd7253baf6565d5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:07:54 +0100 Subject: [PATCH 48/57] fix(api): correct syntax and constraints in headlines schema The server was failing to start due to a syntax error (a missing comma) in the `CREATE TABLE` statement for the `headlines` table. Additionally, the schema incorrectly defined the `image_url`, `url`, and `published_at` columns as `NOT NULL`, which contradicted the nullable fields in the `Headline` data model. This change corrects the syntax error and removes the incorrect `NOT NULL` constraints, fully aligning the database schema with the model and resolving the startup failure. --- lib/src/services/database_seeding_service.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 1038cfa..67f0a90 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -107,13 +107,13 @@ class DatabaseSeedingService { title TEXT NOT NULL, source_id TEXT NOT NULL, category_id TEXT NOT NULL, - image_url TEXT NOT NULL, - url TEXT NOT NULL, - published_at TIMESTAMPTZ NOT NULL, + image_url TEXT, + url TEXT, + published_at TIMESTAMPTZ, description TEXT, content TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ + updated_at TIMESTAMPTZ, status TEXT, type TEXT ); From b971f547058400ba0b846d9e4788beaace70ba25 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:11:12 +0100 Subject: [PATCH 49/57] fix(api): fully align headlines schema and seeder with model The `headlines` table schema incorrectly contained a `content` column not present in the `Headline` model. Additionally, the seeder logic was not robustly handling all nullable fields (like `image_url`), causing a "Missing variable" crash. This change removes the `content` column from the schema and `INSERT` statement. It also updates the seeder to ensure all optional fields from the model are present in the parameter map. This fully aligns the database layer with the data model and resolves the final startup error. --- lib/src/services/database_seeding_service.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 67f0a90..0b81785 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -111,7 +111,6 @@ class DatabaseSeedingService { url TEXT, published_at TIMESTAMPTZ, description TEXT, - content TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, status TEXT, @@ -275,16 +274,18 @@ class DatabaseSeedingService { // Ensure optional fields exist for the postgres driver. params.putIfAbsent('description', () => null); - params.putIfAbsent('content', () => null); params.putIfAbsent('updated_at', () => null); + params.putIfAbsent('image_url', () => null); + params.putIfAbsent('url', () => null); + params.putIfAbsent('published_at', () => null); await _connection.execute( Sql.named( 'INSERT INTO headlines (id, title, source_id, category_id, ' - 'image_url, url, published_at, description, content, status, ' + '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, ' - '@content, @status, @type, @created_at, @updated_at) ' + '@status, @type, @created_at, @updated_at) ' 'ON CONFLICT (id) DO NOTHING', ), parameters: params, From 86844c04e9d917f7e608904ddf8bf3db43853b82 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:16:34 +0100 Subject: [PATCH 50/57] fix(api): correctly serialize user preferences for JSONB seeding The admin user seeding was failing with a JSON syntax error. This was caused by passing raw lists of complex objects (e.g., `List`) to the database driver for `JSONB` columns, which the driver cannot serialize automatically. This change updates the seeding logic to use the `UserContentPreferences.toJson()` method, which correctly serializes the data into a JSON-compatible format before insertion. This resolves the final startup error. --- lib/src/services/database_seeding_service.dart | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 0b81785..2835359 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -386,14 +386,10 @@ class DatabaseSeedingService { '@followed_sources, @followed_countries, @saved_headlines) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - 'id': adminPreferences.id, - 'user_id': adminUser.id, - 'followed_categories': adminPreferences.followedCategories, - 'followed_sources': adminPreferences.followedSources, - 'followed_countries': adminPreferences.followedCountries, - 'saved_headlines': adminPreferences.savedHeadlines, - }, + // 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, ); await _connection.execute('COMMIT'); From b007772fa77075e6ac0b176fce67b697716b78fc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:31:32 +0100 Subject: [PATCH 51/57] fix(api): correctly serialize roles list for admin user seeding The admin user seeding was failing with a JSON syntax error because the `roles` list (`List`) was being passed directly to the database driver for a `JSONB` column. The driver did not correctly serialize this into a valid JSON array string. This change uses `jsonEncode` to explicitly convert the `roles` list into a correctly formatted JSON string before insertion, resolving the final startup error. --- lib/src/services/database_seeding_service.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 2835359..69eda12 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:ht_shared/ht_shared.dart'; import 'package:logging/logging.dart'; import 'package:postgres/postgres.dart'; @@ -353,7 +354,7 @@ class DatabaseSeedingService { parameters: { 'id': adminUser.id, 'email': adminUser.email, - 'roles': adminUser.roles, + 'roles': jsonEncode(adminUser.roles), }, ); From fc53d02e6fa2418dd63eec2e5984ab52deebd5ef Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:41:20 +0100 Subject: [PATCH 52/57] fix(api): correctly serialize user roles for database operations User creation was failing with a JSON syntax error because the `roles` field (`List`) was not being correctly serialized into a JSON array string for the database's `JSONB` column. This change updates the `userRepository`'s configuration to use a custom `toJson` function. This function explicitly JSON-encodes the `roles` list before it's sent to the database client, resolving the error at the source without altering the generic repository or the User model. --- lib/src/config/app_dependencies.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index fbe2ac4..07a6c3f 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:ht_api/src/config/database_connection.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; @@ -124,7 +125,13 @@ class AppDependencies { connection, 'users', User.fromJson, - (u) => u.toJson(), + (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']); + return json; + }, ); userAppSettingsRepository = _createRepository( connection, From 5ad4d7207169c49ffa65950957d23d6b54544c9e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:44:46 +0100 Subject: [PATCH 53/57] fix(api): handle DateTime deserialization from database for User model Anonymous user creation was failing with a type cast error because the `postgres` driver returns native `DateTime` objects, while the `User.fromJson` factory expects ISO 8601 strings for date fields. This change introduces a custom deserialization function for the `userRepository`. This function intercepts the data map from the database, converts `DateTime` objects to the expected string format, and then passes the corrected map to the standard `User.fromJson` factory. This resolves the runtime error by aligning the data format from the data source with the expectations of the data model. --- lib/src/config/app_dependencies.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 07a6c3f..0df6671 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -124,7 +124,18 @@ class AppDependencies { userRepository = _createRepository( connection, 'users', - User.fromJson, + (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); + }, (user) { // The `roles` field is a List, but the database expects a // JSONB array. We must explicitly encode it. From 43454b4690bfedc3d23ccf676e39a996e2fedfeb Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:52:49 +0100 Subject: [PATCH 54/57] fix(api): align user_app_settings schema with data model The user_app_settings table was missing the feed_preferences, engagement_shown_counts, and engagement_last_shown_timestamps columns, causing a crash during new user creation. This change updates the table schema and its corresponding seeding logic to be in perfect sync with the UserAppSettings model, resolving the runtime error. --- .../services/database_seeding_service.dart | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 69eda12..6ba301d 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -126,6 +126,9 @@ class DatabaseSeedingService { user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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 ); @@ -364,19 +367,15 @@ class DatabaseSeedingService { await _connection.execute( Sql.named( - 'INSERT INTO user_app_settings (id, user_id, ' - 'display_settings, language) ' - 'VALUES (@id, @user_id, @display_settings, @language) ' + '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) ' 'ON CONFLICT (id) DO NOTHING', ), - parameters: { - 'id': adminSettings.id, - 'user_id': adminUser.id, - 'display_settings': - adminSettings.displaySettings.toJson(), // This is a complex object - 'language': - adminSettings.language, // This is a simple String - }, + parameters: adminSettings.toJson() + ..['user_id'] = adminUser.id, ); await _connection.execute( From 757baa8d96119c93117ed259507c338f1af9ddc6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 22:56:06 +0100 Subject: [PATCH 55/57] fix(api): correctly serialize and deserialize user content preferences User creation was failing with a type cast error when creating the default UserContentPreferences. This was caused by two issues: 1. Complex list objects were not being JSON-encoded before being sent to the database's JSONB columns. 2. DateTime objects returned from the database were not being converted to strings before being passed to the fromJson factory. This change introduces custom toJson and fromJson functions for the userContentPreferencesRepository. These functions handle the necessary data transformations, ensuring correct serialization and deserialization and resolving the runtime error. --- lib/src/config/app_dependencies.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index 0df6671..ee5f409 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -153,8 +153,27 @@ class AppDependencies { userContentPreferencesRepository = _createRepository( connection, 'user_content_preferences', - UserContentPreferences.fromJson, - (p) => p.toJson(), + (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); + }, + (preferences) { + final json = preferences.toJson(); + json['followed_categories'] = jsonEncode(json['followed_categories']); + 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( connection, From 292d5c896e0879dc368df85f949f10ed5cfacd1b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 23:02:40 +0100 Subject: [PATCH 56/57] fix(api): provide model registry to root middleware The generic data routes were failing with a "Bad state" error because the `ModelRegistryMap` was not being provided to the request context. This change adds the `ModelRegistryMap` to the root middleware's dependency providers, making it available globally and resolving the error in the data route handlers. --- routes/_middleware.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/routes/_middleware.dart b/routes/_middleware.dart index e8ab2c7..f9fb006 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -2,6 +2,7 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/config/app_dependencies.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; import 'package:ht_api/src/services/dashboard_summary_service.dart'; @@ -113,6 +114,7 @@ Handler middleware(Handler handler) { // 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)) From b81371a3c9e99abbc828458df3a4b1ac2e886698 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 6 Jul 2025 23:08:01 +0100 Subject: [PATCH 57/57] fix(api): correct data serialization for all repositories The generic data routes were failing with a type cast error (`DateTime` is not a subtype of `String?`) when reading from the database. This was because the `postgres` driver returns native `DateTime` objects, while the models' `fromJson` factories expect ISO 8601 strings. This change implements custom `fromJson` functions for all relevant repositories (`headline`, `category`, `source`, `country`, `appConfig`, `userAppSettings`). These functions pre-process the data from the database, converting `DateTime` objects to the expected string format before deserialization. Additionally, it corrects the `toJson` logic for the `userAppSettingsRepository` to properly JSON-encode its complex fields for `JSONB` columns. This resolves all known data serialization and deserialization errors. --- lib/src/config/app_dependencies.dart | 86 +++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index ee5f409..ee2d104 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -100,25 +100,69 @@ class AppDependencies { headlineRepository = _createRepository( connection, 'headlines', - Headline.fromJson, - (h) => h.toJson(), + (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); + }, + (h) => h.toJson(), // toJson already handles DateTime correctly ); categoryRepository = _createRepository( connection, 'categories', - Category.fromJson, + (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(), ); sourceRepository = _createRepository( connection, 'sources', - Source.fromJson, + (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); + }, (s) => s.toJson(), ); countryRepository = _createRepository( connection, 'countries', - Country.fromJson, + (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(), ); userRepository = _createRepository( @@ -147,8 +191,24 @@ class AppDependencies { userAppSettingsRepository = _createRepository( connection, 'user_app_settings', - UserAppSettings.fromJson, - (s) => s.toJson(), + (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); + }, + (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, @@ -178,7 +238,17 @@ class AppDependencies { appConfigRepository = _createRepository( connection, 'app_config', - AppConfig.fromJson, + (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); + }, (c) => c.toJson(), );