Skip to content

Commit f27e209

Browse files
authored
Merge pull request #11 from headlines-toolkit/feat_postgres_integration
Feat postgres integration
2 parents 671691f + cccd890 commit f27e209

File tree

8 files changed

+621
-317
lines changed

8 files changed

+621
-317
lines changed

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,23 +66,38 @@ for more details.
6666

6767
1. **Prerequisites:**
6868
* Dart SDK (`>=3.0.0`)
69+
* PostgreSQL (`>=14.0` recommended)
6970
* Dart Frog CLI (`dart pub global activate dart_frog_cli`)
70-
2. **Clone the repository:**
71+
72+
2. **Configuration:**
73+
Before running the server, you must configure the database connection by
74+
setting the `DATABASE_URL` environment variable.
75+
76+
Create a `.env` file in the root of the project or export the variable in
77+
your shell:
78+
```
79+
DATABASE_URL="postgres://user:password@localhost:5432/ht_api_db"
80+
```
81+
82+
3. **Clone the repository:**
7183
```bash
7284
git clone https://github.com/headlines-toolkit/ht-api.git
7385
cd ht-api
7486
```
75-
3. **Get dependencies:**
87+
4. **Get dependencies:**
7688
```bash
7789
dart pub get
7890
```
79-
4. **Run the development server:**
91+
5. **Run the development server:**
8092
```bash
8193
dart_frog dev
8294
```
83-
The API will typically be available at `http://localhost:8080`. Fixture data
84-
from `lib/src/fixtures/` will be loaded into the in-memory repositories on
85-
startup.
95+
The API will typically be available at `http://localhost:8080`. On the
96+
first startup, the server will connect to your PostgreSQL database, create the
97+
necessary tables, and seed them with initial fixture data. This process is
98+
non-destructive; it uses `CREATE TABLE IF NOT EXISTS` and `INSERT ... ON
99+
CONFLICT DO NOTHING` to avoid overwriting existing tables or data.
100+
86101
87102
**Note on Web Client Integration (CORS):**
88103
To allow web applications (like the HT Dashboard) to connect to this API,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import 'dart:io';
2+
3+
/// {@template environment_config}
4+
/// A utility class for accessing environment variables.
5+
///
6+
/// This class provides a centralized way to read configuration values
7+
/// from the environment, ensuring that critical settings like database
8+
/// connection strings are managed outside of the source code.
9+
/// {@endtemplate}
10+
abstract final class EnvironmentConfig {
11+
/// Retrieves the PostgreSQL database connection URI from the environment.
12+
///
13+
/// The value is read from the `DATABASE_URL` environment variable.
14+
///
15+
/// Throws a [StateError] if the `DATABASE_URL` environment variable is not
16+
/// set, as the application cannot function without it.
17+
static String get databaseUrl {
18+
final dbUrl = Platform.environment['DATABASE_URL'];
19+
if (dbUrl == null || dbUrl.isEmpty) {
20+
throw StateError(
21+
'FATAL: DATABASE_URL environment variable is not set. '
22+
'The application cannot start without a database connection.',
23+
);
24+
}
25+
return dbUrl;
26+
}
27+
28+
/// Retrieves the current environment mode (e.g., 'development', 'production').
29+
///
30+
/// The value is read from the `ENV` environment variable.
31+
/// Defaults to 'production' if the variable is not set.
32+
static String get environment => Platform.environment['ENV'] ?? 'production';
33+
}

lib/src/config/server.dart

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import 'dart:io';
2+
3+
import 'package:dart_frog/dart_frog.dart';
4+
import 'package:ht_api/src/config/environment_config.dart';
5+
import 'package:ht_api/src/rbac/permission_service.dart';
6+
import 'package:ht_api/src/services/auth_service.dart';
7+
import 'package:ht_api/src/services/auth_token_service.dart';
8+
import 'package:ht_api/src/services/dashboard_summary_service.dart';
9+
import 'package:ht_api/src/services/database_seeding_service.dart';
10+
import 'package:ht_api/src/services/default_user_preference_limit_service.dart';
11+
import 'package:ht_api/src/services/jwt_auth_token_service.dart';
12+
import 'package:ht_api/src/services/token_blacklist_service.dart';
13+
import 'package:ht_api/src/services/user_preference_limit_service.dart';
14+
import 'package:ht_api/src/services/verification_code_storage_service.dart';
15+
import 'package:ht_data_client/ht_data_client.dart';
16+
import 'package:ht_data_postgres/ht_data_postgres.dart';
17+
import 'package:ht_data_repository/ht_data_repository.dart';
18+
import 'package:ht_email_inmemory/ht_email_inmemory.dart';
19+
import 'package:ht_email_repository/ht_email_repository.dart';
20+
import 'package:ht_shared/ht_shared.dart';
21+
import 'package:logging/logging.dart';
22+
import 'package:postgres/postgres.dart';
23+
import 'package:uuid/uuid.dart';
24+
25+
/// Global logger instance.
26+
final _log = Logger('ht_api');
27+
28+
/// Global PostgreSQL connection instance.
29+
late final Connection _connection;
30+
31+
/// Creates a data repository for a given type [T].
32+
///
33+
/// This helper function centralizes the creation of repositories,
34+
/// ensuring they all use the same database connection and logger.
35+
HtDataRepository<T> _createRepository<T>({
36+
required String tableName,
37+
required FromJson<T> fromJson,
38+
required ToJson<T> toJson,
39+
}) {
40+
return HtDataRepository<T>(
41+
dataClient: HtDataPostgresClient<T>(
42+
connection: _connection,
43+
tableName: tableName,
44+
fromJson: fromJson,
45+
toJson: toJson,
46+
log: _log,
47+
),
48+
);
49+
}
50+
51+
/// The main entry point for the server.
52+
///
53+
/// This function is responsible for:
54+
/// 1. Setting up the global logger.
55+
/// 2. Establishing the PostgreSQL database connection.
56+
/// 3. Providing these dependencies to the Dart Frog handler.
57+
/// 4. Gracefully closing the database connection on server shutdown.
58+
Future<HttpServer> run(Handler handler, InternetAddress ip, int port) async {
59+
// 1. Setup Logger
60+
Logger.root.level = Level.ALL;
61+
Logger.root.onRecord.listen((record) {
62+
// ignore: avoid_print
63+
print(
64+
'${record.level.name}: ${record.time}: '
65+
'${record.loggerName}: ${record.message}',
66+
);
67+
});
68+
69+
// 2. Establish Database Connection
70+
_log.info('Connecting to PostgreSQL database...');
71+
final dbUri = Uri.parse(EnvironmentConfig.databaseUrl);
72+
String? username;
73+
String? password;
74+
if (dbUri.userInfo.isNotEmpty) {
75+
final parts = dbUri.userInfo.split(':');
76+
username = Uri.decodeComponent(parts.first);
77+
if (parts.length > 1) {
78+
password = Uri.decodeComponent(parts.last);
79+
}
80+
}
81+
82+
_connection = await Connection.open(
83+
Endpoint(
84+
host: dbUri.host,
85+
port: dbUri.port,
86+
database: dbUri.path.substring(1), // Remove leading '/'
87+
username: username,
88+
password: password,
89+
),
90+
// Using `require` is a more secure default. For local development against
91+
// a non-SSL database, this may need to be changed to `SslMode.disable`.
92+
settings: const ConnectionSettings(sslMode: SslMode.require),
93+
);
94+
_log.info('PostgreSQL database connection established.');
95+
96+
// 3. Initialize and run database seeding
97+
// This runs on every startup. The operations are idempotent (`IF NOT EXISTS`,
98+
// `ON CONFLICT DO NOTHING`), so it's safe to run every time. This ensures
99+
// the database is always in a valid state, especially for first-time setup
100+
// in any environment.
101+
final seedingService = DatabaseSeedingService(
102+
connection: _connection,
103+
log: _log,
104+
);
105+
await seedingService.createTables();
106+
await seedingService.seedGlobalFixtureData();
107+
await seedingService.seedInitialAdminAndConfig();
108+
109+
// 4. Initialize Repositories
110+
final headlineRepository = _createRepository<Headline>(
111+
tableName: 'headlines',
112+
fromJson: Headline.fromJson,
113+
toJson: (h) => h.toJson(),
114+
);
115+
final categoryRepository = _createRepository<Category>(
116+
tableName: 'categories',
117+
fromJson: Category.fromJson,
118+
toJson: (c) => c.toJson(),
119+
);
120+
final sourceRepository = _createRepository<Source>(
121+
tableName: 'sources',
122+
fromJson: Source.fromJson,
123+
toJson: (s) => s.toJson(),
124+
);
125+
final countryRepository = _createRepository<Country>(
126+
tableName: 'countries',
127+
fromJson: Country.fromJson,
128+
toJson: (c) => c.toJson(),
129+
);
130+
final userRepository = _createRepository<User>(
131+
tableName: 'users',
132+
fromJson: User.fromJson,
133+
toJson: (u) => u.toJson(),
134+
);
135+
final userAppSettingsRepository = _createRepository<UserAppSettings>(
136+
tableName: 'user_app_settings',
137+
fromJson: UserAppSettings.fromJson,
138+
toJson: (s) => s.toJson(),
139+
);
140+
final userContentPreferencesRepository =
141+
_createRepository<UserContentPreferences>(
142+
tableName: 'user_content_preferences',
143+
fromJson: UserContentPreferences.fromJson,
144+
toJson: (p) => p.toJson(),
145+
);
146+
final appConfigRepository = _createRepository<AppConfig>(
147+
tableName: 'app_config',
148+
fromJson: AppConfig.fromJson,
149+
toJson: (c) => c.toJson(),
150+
);
151+
152+
// 5. Initialize Services
153+
const uuid = Uuid();
154+
const emailRepository = HtEmailRepository(
155+
emailClient: HtEmailInMemoryClient(),
156+
);
157+
final tokenBlacklistService = InMemoryTokenBlacklistService();
158+
final authTokenService = JwtAuthTokenService(
159+
userRepository: userRepository,
160+
blacklistService: tokenBlacklistService,
161+
uuidGenerator: uuid,
162+
);
163+
final verificationCodeStorageService =
164+
InMemoryVerificationCodeStorageService();
165+
final authService = AuthService(
166+
userRepository: userRepository,
167+
authTokenService: authTokenService,
168+
verificationCodeStorageService: verificationCodeStorageService,
169+
emailRepository: emailRepository,
170+
userAppSettingsRepository: userAppSettingsRepository,
171+
userContentPreferencesRepository: userContentPreferencesRepository,
172+
uuidGenerator: uuid,
173+
);
174+
final dashboardSummaryService = DashboardSummaryService(
175+
headlineRepository: headlineRepository,
176+
categoryRepository: categoryRepository,
177+
sourceRepository: sourceRepository,
178+
);
179+
const permissionService = PermissionService();
180+
final userPreferenceLimitService = DefaultUserPreferenceLimitService(
181+
appConfigRepository: appConfigRepository,
182+
);
183+
184+
// 6. Create the main handler with all dependencies provided
185+
final finalHandler = handler
186+
// Foundational utilities
187+
.use(provider<Uuid>((_) => uuid))
188+
// Repositories
189+
.use(provider<HtDataRepository<Headline>>((_) => headlineRepository))
190+
.use(provider<HtDataRepository<Category>>((_) => categoryRepository))
191+
.use(provider<HtDataRepository<Source>>((_) => sourceRepository))
192+
.use(provider<HtDataRepository<Country>>((_) => countryRepository))
193+
.use(provider<HtDataRepository<User>>((_) => userRepository))
194+
.use(
195+
provider<HtDataRepository<UserAppSettings>>(
196+
(_) => userAppSettingsRepository,
197+
),
198+
)
199+
.use(
200+
provider<HtDataRepository<UserContentPreferences>>(
201+
(_) => userContentPreferencesRepository,
202+
),
203+
)
204+
.use(provider<HtDataRepository<AppConfig>>((_) => appConfigRepository))
205+
.use(provider<HtEmailRepository>((_) => emailRepository))
206+
// Services
207+
.use(provider<TokenBlacklistService>((_) => tokenBlacklistService))
208+
.use(provider<AuthTokenService>((_) => authTokenService))
209+
.use(
210+
provider<VerificationCodeStorageService>(
211+
(_) => verificationCodeStorageService,
212+
),
213+
)
214+
.use(provider<AuthService>((_) => authService))
215+
.use(provider<DashboardSummaryService>((_) => dashboardSummaryService))
216+
.use(provider<PermissionService>((_) => permissionService))
217+
.use(
218+
provider<UserPreferenceLimitService>((_) => userPreferenceLimitService),
219+
);
220+
221+
// 7. Start the server
222+
final server = await serve(finalHandler, ip, port);
223+
_log.info('Server listening on port ${server.port}');
224+
225+
// 8. Handle graceful shutdown
226+
ProcessSignal.sigint.watch().listen((_) async {
227+
_log.info('Received SIGINT. Shutting down...');
228+
await _connection.close();
229+
_log.info('Database connection closed.');
230+
await server.close(force: true);
231+
_log.info('Server shut down.');
232+
exit(0);
233+
});
234+
235+
return server;
236+
}

lib/src/registry/model_registry.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// ignore_for_file: comment_references
2+
13
import 'package:dart_frog/dart_frog.dart';
24
import 'package:ht_api/src/rbac/permissions.dart';
35
import 'package:ht_data_client/ht_data_client.dart';

lib/src/services/auth_service.dart

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,7 @@ class AuthService {
171171
// Admin users must be provisioned out-of-band (e.g., via fixtures).
172172
final roles = [UserRoles.standardUser];
173173

174-
user = User(
175-
id: _uuid.v4(),
176-
email: email,
177-
roles: roles,
178-
);
174+
user = User(id: _uuid.v4(), email: email, roles: roles);
179175
user = await _userRepository.create(item: user);
180176
print('Created new user: ${user.id} with roles: ${user.roles}');
181177

@@ -197,7 +193,9 @@ class AuthService {
197193
}
198194
} on HtHttpException catch (e) {
199195
print('Error finding/creating user for $email: $e');
200-
throw const OperationFailedException('Failed to find or create user account.');
196+
throw const OperationFailedException(
197+
'Failed to find or create user account.',
198+
);
201199
} catch (e) {
202200
print('Unexpected error during user lookup/creation for $email: $e');
203201
throw const OperationFailedException('Failed to process user account.');
@@ -213,7 +211,7 @@ class AuthService {
213211
throw const OperationFailedException(
214212
'Failed to generate authentication token.',
215213
);
216-
}
214+
}
217215
}
218216

219217
/// Performs anonymous sign-in.
@@ -227,7 +225,7 @@ class AuthService {
227225
try {
228226
user = User(
229227
id: _uuid.v4(), // Generate new ID
230-
roles: [UserRoles.guestUser], // Anonymous users are guest users
228+
roles: const [UserRoles.guestUser], // Anonymous users are guest users
231229
email: null, // Anonymous users don't have an email initially
232230
);
233231
user = await _userRepository.create(item: user);
@@ -426,7 +424,7 @@ class AuthService {
426424
final updatedUser = User(
427425
id: anonymousUser.id, // Preserve original ID
428426
email: linkedEmail,
429-
roles: [UserRoles.standardUser], // Now a permanent standard user
427+
roles: const [UserRoles.standardUser], // Now a permanent standard user
430428
);
431429
final permanentUser = await _userRepository.update(
432430
id: updatedUser.id,

0 commit comments

Comments
 (0)