Refactor database layer: convert to DatabaseProvider class with initialization

This commit is contained in:
dmit.b
2026-05-08 12:15:56 +03:00
commit 49bb854ca2
28 changed files with 2011 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
import 'dart:io';
import 'package:bcrypt/bcrypt.dart';
import 'package:dotenv/dotenv.dart';
import 'package:postgres/postgres.dart';
import 'package:uuid/uuid.dart';
import '../models/user.dart';
import '../models/geoposition.dart';
import '../models/log.dart';
class DatabaseProvider {
late Connection _dbConnection;
final Map<String, int> _shareLinks = {};
final _uuid = const Uuid();
Future<void> initialize() async {
final dotenv = DotEnv();
final host = dotenv['POSTGRES_HOST'] ?? 'localhost';
final port = int.parse(dotenv['POSTGRES_PORT'] ?? '5432');
final databaseName = dotenv['POSTGRES_DB'] ?? 'family_safety';
final username = dotenv['POSTGRES_USER'] ?? 'postgres';
final password = dotenv['POSTGRES_PASSWORD'] ?? '';
try {
final defaultConnection = await Connection.open(
settings: ConnectionSettings(sslMode: SslMode.disable),
Endpoint(
host: host,
port: port,
database: 'postgres',
username: username,
password: password,
),
);
final results = await defaultConnection.execute(
Sql.named('SELECT 1 FROM pg_database WHERE datname = @dbName'),
parameters: {'dbName': databaseName},
);
if (results.isEmpty) {
await defaultConnection.execute(
Sql.named('CREATE DATABASE @dbName'),
parameters: {'dbName': databaseName},
);
print('Database $databaseName created.');
} else {
print('Database $databaseName already exists.');
}
await defaultConnection.close();
_dbConnection = await Connection.open(
settings: ConnectionSettings(sslMode: SslMode.disable),
Endpoint(
host: host,
port: port,
database: databaseName,
username: username,
password: password,
),
);
print('Connected to database $databaseName.');
await _dbConnection.execute(
Sql.named('''
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
login VARCHAR(255) UNIQUE NOT NULL,
pwd_hash VARCHAR(255) NOT NULL
)
'''),
);
await _dbConnection.execute(
Sql.named('''
CREATE TABLE IF NOT EXISTS geopositions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
x_value DOUBLE PRECISION NOT NULL,
y_value DOUBLE PRECISION NOT NULL,
datetime TIMESTAMP NOT NULL DEFAULT NOW(),
lifetime INTERVAL NOT NULL,
expires_at TIMESTAMP NOT NULL
)
'''),
);
await _dbConnection.execute(
Sql.named('''
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
action VARCHAR(255) NOT NULL,
datetime TIMESTAMP NOT NULL DEFAULT NOW()
)
'''),
);
print('All tables ensured to exist.');
} catch (e, stackTrace) {
stderr.writeln('Database initialization error: $e');
stderr.writeln(stackTrace);
exit(1);
}
}
Future<void> close() async {
await _dbConnection.close();
}
// ==================== User operations ====================
Future<User?> findUserByLogin(String login) async {
final results = await _dbConnection.execute(
Sql.named('SELECT id, login, pwd_hash FROM users WHERE login = @login'),
parameters: {'login': login},
);
if (results.isEmpty) return null;
final row = results.first;
return User(
id: int.parse(row[0].toString()),
login: row[1] as String,
pwdHash: row[2] as String,
);
}
Future<User> createUser(String login, String password) async {
final hashedPassword = BCrypt.hashpw(password, BCrypt.gensalt());
final results = await _dbConnection.execute(
Sql.named('''
INSERT INTO users (login, pwd_hash) VALUES (@login, @pwdHash)
RETURNING id, login, pwd_hash
'''),
parameters: {
'login': login,
'pwdHash': hashedPassword,
},
);
final row = results.first;
return User(
id: int.parse(row[0].toString()),
login: row[1] as String,
pwdHash: row[2] as String,
);
}
Future<void> deleteUser(int id) async {
await _dbConnection.execute(
Sql.named('DELETE FROM users WHERE id = @id'),
parameters: {'id': id},
);
}
Future<User> updateUser(int id, String newLogin, String? newPassword) async {
if (newPassword != null) {
final hashedPassword = BCrypt.hashpw(newPassword, BCrypt.gensalt());
await _dbConnection.execute(
Sql.named('''
UPDATE users SET login = @login, pwd_hash = @pwdHash WHERE id = @id
'''),
parameters: {
'login': newLogin,
'pwdHash': hashedPassword,
'id': id,
},
);
} else {
await _dbConnection.execute(
Sql.named('UPDATE users SET login = @login WHERE id = @id'),
parameters: {
'login': newLogin,
'id': id,
},
);
}
final user = await findUserByLogin(newLogin);
return user!;
}
Future<List<User>> getAllUsers() async {
final results = await _dbConnection.execute(
Sql.named('SELECT id, login, pwd_hash FROM users'),
);
return results
.map(
(row) => User(
id: int.parse(row[0].toString()),
login: row[1] as String,
pwdHash: row[2] as String,
),
)
.toList();
}
// ==================== Geoposition operations ====================
Future<Geoposition> createPosition(
int userId,
double x,
double y,
Duration lifetime,
) async {
final expiresAt = DateTime.now().add(lifetime);
final results = await _dbConnection.execute(
Sql.named('''
INSERT INTO geopositions (user_id, x_value, y_value, datetime, lifetime, expires_at)
VALUES (@userId, @xValue, @yValue, NOW(), @lifetime, @expiresAt)
RETURNING id, user_id, x_value, y_value, datetime, lifetime, expires_at
'''),
parameters: {
'userId': userId,
'xValue': x,
'yValue': y,
'lifetime': _toInterval(lifetime),
'expiresAt': expiresAt.toIso8601String(),
},
);
final row = results.first;
return Geoposition(
id: int.parse(row[0].toString()),
userId: int.parse(row[1].toString()),
xValue: double.parse(row[2].toString()),
yValue: double.parse(row[3].toString()),
datetime: DateTime.parse(row[4].toString()),
lifetime: lifetime,
expiresAt: DateTime.parse(row[6].toString()),
);
}
Future<Geoposition> updatePosition(
int userId,
double x,
double y,
Duration lifetime,
) async {
final expiresAt = DateTime.now().add(lifetime);
final results = await _dbConnection.execute(
Sql.named('''
INSERT INTO geopositions (user_id, x_value, y_value, datetime, lifetime, expires_at)
VALUES (@userId, @xValue, @yValue, NOW(), @lifetime, @expiresAt)
RETURNING id, user_id, x_value, y_value, datetime, lifetime, expires_at
'''),
parameters: {
'userId': userId,
'xValue': x,
'yValue': y,
'lifetime': _toInterval(lifetime),
'expiresAt': expiresAt.toIso8601String(),
},
);
final row = results.first;
return Geoposition(
id: int.parse(row[0].toString()),
userId: int.parse(row[1].toString()),
xValue: double.parse(row[2].toString()),
yValue: double.parse(row[3].toString()),
datetime: DateTime.parse(row[4].toString()),
lifetime: lifetime,
expiresAt: DateTime.parse(row[6].toString()),
);
}
Future<Geoposition?> getLatestPosition(int userId) async {
final results = await _dbConnection.execute(
Sql.named('''
SELECT id, user_id, x_value, y_value, datetime, lifetime, expires_at
FROM geopositions
WHERE user_id = @userId AND expires_at > NOW()
ORDER BY datetime DESC
LIMIT 1
'''),
parameters: {'userId': userId},
);
if (results.isEmpty) return null;
final row = results.first;
return Geoposition(
id: int.parse(row[0].toString()),
userId: int.parse(row[1].toString()),
xValue: double.parse(row[2].toString()),
yValue: double.parse(row[3].toString()),
datetime: DateTime.parse(row[4].toString()),
lifetime: Duration(
seconds: int.tryParse(row[5].toString()) ?? 0),
expiresAt: DateTime.parse(row[6].toString()),
);
}
Future<void> cleanupExpired() async {
await _dbConnection.execute(
Sql.named('DELETE FROM geopositions WHERE expires_at < NOW()'),
);
}
// ==================== Share operations ====================
String createShareId(int userId) {
final uniqueId = _uuid.v4();
_shareLinks[uniqueId] = userId;
return uniqueId;
}
int? getUserIdByShareId(String uniqueId) {
return _shareLinks[uniqueId];
}
// ==================== Log operations ====================
Future<void> createLog(String username, String action) async {
await _dbConnection.execute(
Sql.named('''
INSERT INTO logs (username, action) VALUES (@username, @action)
'''),
parameters: {
'username': username,
'action': action,
},
);
}
Future<List<Log>> getAllLogs() async {
final results = await _dbConnection.execute(
Sql.named('SELECT id, username, action, datetime FROM logs'),
);
return results
.map(
(row) => Log(
id: int.parse(row[0].toString()),
username: row[1] as String,
action: row[2] as String,
datetime: DateTime.parse(row[3].toString()),
),
)
.toList();
}
String _toInterval(Duration duration) {
final seconds = duration.inSeconds;
return '$seconds seconds';
}
}
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
login VARCHAR(255) UNIQUE NOT NULL,
pwd_hash VARCHAR(255) NOT NULL
);
CREATE TABLE IF NOT EXISTS geopositions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
x_value DOUBLE PRECISION NOT NULL,
y_value DOUBLE PRECISION NOT NULL,
datetime TIMESTAMP NOT NULL DEFAULT NOW(),
lifetime INTERVAL NOT NULL,
expires_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
action VARCHAR(255) NOT NULL,
datetime TIMESTAMP NOT NULL DEFAULT NOW()
);
+41
View File
@@ -0,0 +1,41 @@
class Geoposition {
final int id;
final int userId;
final double xValue;
final double yValue;
final DateTime datetime;
final Duration lifetime;
final DateTime expiresAt;
Geoposition({
required this.id,
required this.userId,
required this.xValue,
required this.yValue,
required this.datetime,
required this.lifetime,
required this.expiresAt,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'x': xValue,
'y': yValue,
'datetime': datetime.toIso8601String(),
'remainingSeconds': expiresAt.difference(DateTime.now()).inSeconds,
};
}
factory Geoposition.fromMap(Map<String, dynamic> map) {
return Geoposition(
id: map['id'],
userId: map['user_id'],
xValue: map['x_value'].toDouble(),
yValue: map['y_value'].toDouble(),
datetime: map['datetime'] as DateTime,
lifetime: Duration(seconds: map['lifetime']),
expiresAt: map['expires_at'] as DateTime,
);
}
}
+22
View File
@@ -0,0 +1,22 @@
class Log {
final int id;
final String username;
final String action;
final DateTime datetime;
Log({
required this.id,
required this.username,
required this.action,
required this.datetime,
});
factory Log.fromMap(Map<String, dynamic> map) {
return Log(
id: map['id'],
username: map['username'],
action: map['action'],
datetime: map['datetime'] as DateTime,
);
}
}
+26
View File
@@ -0,0 +1,26 @@
class User {
final int id;
final String login;
final String pwdHash;
User({
required this.id,
required this.login,
required this.pwdHash,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'login': login,
};
}
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'],
login: map['login'],
pwdHash: map['pwd_hash'],
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:bcrypt/bcrypt.dart';
import '../database/database_provider.dart';
import 'dart:convert';
class AuthRoutes {
final DatabaseProvider database;
AuthRoutes(this.database);
Router get routes {
final router = Router();
router.post('/login', _login);
router.get('/watch', _watch);
return router;
}
Future<Response> _login(Request request) async {
final body = await request.readAsString();
final data = jsonDecode(body);
final login = data['login'];
final password = data['password'];
final user = await database.findUserByLogin(login);
if (user == null || !BCrypt.checkpw(password, user.pwdHash)) {
return Response(401, body: 'Invalid credentials');
}
return Response(200, body: jsonEncode({'user': user.toMap()}));
}
Future<Response> _watch(Request request) async {
final uniqueId = request.url.queryParameters['unique_id'];
final userId = database.getUserIdByShareId(uniqueId!);
if (userId == null) {
return Response(404, body: 'Share link not found');
}
final position = await database.getLatestPosition(userId);
if (position == null) {
return Response(404, body: 'No position available');
}
return Response(200, body: position.toJson());
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database_provider.dart';
import 'dart:convert';
class GeoRoutes {
final DatabaseProvider database;
GeoRoutes(this.database);
Router get routes {
final router = Router();
router.post('/geo', _createPosition);
router.put('/geo', _updatePosition);
router.post('/share', _createShare);
return router;
}
Future<Response> _createPosition(Request request) async {
final body = await request.readAsString();
final data = jsonDecode(body);
final userId = data['user_id'];
final x = data['x'];
final y = data['y'];
final lifetimeSeconds = data['lifetime'];
final lifetime = Duration(seconds: lifetimeSeconds);
final position = await database.createPosition(userId, x, y, lifetime);
return Response(201, body: position.toJson());
}
Future<Response> _updatePosition(Request request) async {
final body = await request.readAsString();
final data = jsonDecode(body);
final userId = data['user_id'];
final x = data['x'];
final y = data['y'];
final lifetimeSeconds = data['lifetime'];
final lifetime = Duration(seconds: lifetimeSeconds);
final position = await database.updatePosition(userId, x, y, lifetime);
return Response(200, body: position.toJson());
}
Future<Response> _createShare(Request request) async {
final body = await request.readAsString();
final data = jsonDecode(body);
final userId = data['user_id'];
final shareId = database.createShareId(userId);
return Response(200, body: jsonEncode({'share_id': shareId}));
}
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../database/database_provider.dart';
import 'dart:convert';
class UserRoutes {
final DatabaseProvider database;
UserRoutes(this.database);
Router get routes {
final router = Router();
router.get('/user', _getAllUsers);
router.post('/user', _createUser);
router.put('/user/<id>', _updateUser);
router.delete('/user/<id>', _deleteUser);
return router;
}
Future<Response> _getAllUsers(Request request) async {
final users = await database.getAllUsers();
return Response(200, body: jsonEncode(users.map((u) => u.toMap()).toList()));
}
Future<Response> _createUser(Request request) async {
final body = await request.readAsString();
final data = jsonDecode(body);
final login = data['login'];
final password = data['password'];
final user = await database.createUser(login, password);
return Response(201, body: jsonEncode(user.toMap()));
}
Future<Response> _updateUser(Request request) async {
final id = int.parse(request.params['id']!);
final body = await request.readAsString();
final data = jsonDecode(body);
final login = data['login'];
final password = data['password'];
final user = await database.updateUser(id, login, password);
return Response(200, body: jsonEncode(user.toMap()));
}
Future<Response> _deleteUser(Request request) async {
final id = int.parse(request.params['id']!);
await database.deleteUser(id);
return Response(204);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io';
import 'dart:async';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';
import 'database/database_provider.dart';
import 'routes/auth_routes.dart';
import 'routes/user_routes.dart';
import 'routes/geo_routes.dart';
void main(List<String> args) async {
final database = DatabaseProvider();
await database.initialize();
Timer.periodic(const Duration(minutes: 5), (timer) {
database.cleanupExpired();
});
final authRoutes = AuthRoutes(database);
final userRoutes = UserRoutes(database);
final geoRoutes = GeoRoutes(database);
final router = Router()
..mount('', authRoutes.routes.call)
..mount('', userRoutes.routes.call)
..mount('', geoRoutes.routes.call)
..get('/', (Request req) => Response.ok('Family Safety Tracker API\n'));
final handler = Pipeline()
.addMiddleware(logRequests())
.addHandler(router.call);
final ip = InternetAddress.anyIPv4;
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}');
}