Initial scaffold: FocusFlow ADHD Task Manager backend
Dart Shelf API with modules: auth (JWT + PBKDF2), tasks (CRUD + dopamine scorer), streaks (forgiveness + freeze), rewards (variable reward engine), time perception, sync (offline-first push/pull), rooms (body doubling placeholder). Includes DB migration (001_initial_schema.sql) and Docker Compose. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
189
bin/migrate.dart
Normal file
189
bin/migrate.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:focusflow_api/src/config/database.dart';
|
||||
import 'package:focusflow_api/src/config/env.dart';
|
||||
|
||||
final _log = Logger('Migrate');
|
||||
|
||||
/// Run database migrations.
|
||||
///
|
||||
/// Usage: dart run bin/migrate.dart
|
||||
Future<void> main() async {
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'${record.time} [${record.level.name}] ${record.loggerName}: '
|
||||
'${record.message}',
|
||||
);
|
||||
});
|
||||
|
||||
Env.init();
|
||||
await Database.init();
|
||||
|
||||
_log.info('Running migrations...');
|
||||
|
||||
try {
|
||||
// ── Users ──────────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
''');
|
||||
_log.info(' users: OK');
|
||||
|
||||
// ── Refresh tokens ────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
''');
|
||||
_log.info(' refresh_tokens: OK');
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
energy_level INT NOT NULL DEFAULT 3,
|
||||
estimated_minutes INT DEFAULT 25,
|
||||
actual_minutes INT,
|
||||
due_date TIMESTAMPTZ,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
times_postponed INT NOT NULL DEFAULT 0,
|
||||
last_interacted_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
''');
|
||||
_log.info(' tasks: OK');
|
||||
|
||||
// ── Streaks ───────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS streaks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
grace_days INT NOT NULL DEFAULT 1,
|
||||
current_count INT NOT NULL DEFAULT 0,
|
||||
longest_count INT NOT NULL DEFAULT 0,
|
||||
frozen_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
''');
|
||||
_log.info(' streaks: OK');
|
||||
|
||||
// ── Streak entries ────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS streak_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
streak_id TEXT NOT NULL REFERENCES streaks(id),
|
||||
entry_date DATE NOT NULL,
|
||||
entry_type TEXT NOT NULL DEFAULT 'completion',
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(streak_id, entry_date)
|
||||
);
|
||||
''');
|
||||
_log.info(' streak_entries: OK');
|
||||
|
||||
// ── Rewards ───────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS rewards (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
task_id TEXT REFERENCES tasks(id),
|
||||
points INT NOT NULL,
|
||||
magnitude DOUBLE PRECISION NOT NULL,
|
||||
visual_type TEXT NOT NULL,
|
||||
is_surprise BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
breakdown TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
''');
|
||||
_log.info(' rewards: OK');
|
||||
|
||||
// ── Time entries ──────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS time_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
estimated_minutes INT,
|
||||
actual_minutes INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(task_id)
|
||||
);
|
||||
''');
|
||||
_log.info(' time_entries: OK');
|
||||
|
||||
// ── Sync log ──────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
data TEXT,
|
||||
version INT NOT NULL DEFAULT 0,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(entity_type, entity_id, user_id)
|
||||
);
|
||||
''');
|
||||
_log.info(' sync_log: OK');
|
||||
|
||||
// ── Indexes ───────────────────────────────────────────────────────
|
||||
await Database.query('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_user_status
|
||||
ON tasks(user_id, status) WHERE deleted_at IS NULL;
|
||||
''');
|
||||
await Database.query('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_user_due
|
||||
ON tasks(user_id, due_date) WHERE deleted_at IS NULL;
|
||||
''');
|
||||
await Database.query('''
|
||||
CREATE INDEX IF NOT EXISTS idx_streaks_user
|
||||
ON streaks(user_id);
|
||||
''');
|
||||
await Database.query('''
|
||||
CREATE INDEX IF NOT EXISTS idx_rewards_user
|
||||
ON rewards(user_id, created_at);
|
||||
''');
|
||||
await Database.query('''
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_log_user_time
|
||||
ON sync_log(user_id, synced_at);
|
||||
''');
|
||||
_log.info(' indexes: OK');
|
||||
|
||||
_log.info('All migrations complete!');
|
||||
} catch (e, st) {
|
||||
_log.severe('Migration failed', e, st);
|
||||
} finally {
|
||||
await Database.close();
|
||||
exit(0);
|
||||
}
|
||||
}
|
||||
144
bin/server.dart
Normal file
144
bin/server.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import 'package:focusflow_api/src/config/database.dart';
|
||||
import 'package:focusflow_api/src/config/env.dart';
|
||||
import 'package:focusflow_api/src/middleware/auth_middleware.dart';
|
||||
import 'package:focusflow_api/src/middleware/cors_middleware.dart';
|
||||
import 'package:focusflow_api/src/middleware/error_handler.dart';
|
||||
import 'package:focusflow_api/src/middleware/logging_middleware.dart';
|
||||
import 'package:focusflow_api/src/middleware/rate_limit_middleware.dart';
|
||||
import 'package:focusflow_api/src/modules/auth/auth_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/auth/auth_service.dart';
|
||||
import 'package:focusflow_api/src/modules/auth/password_hasher.dart';
|
||||
import 'package:focusflow_api/src/modules/auth/token_service.dart';
|
||||
import 'package:focusflow_api/src/modules/rewards/reward_engine.dart';
|
||||
import 'package:focusflow_api/src/modules/rewards/reward_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/rewards/reward_service.dart';
|
||||
import 'package:focusflow_api/src/modules/rooms/room_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/rooms/room_service.dart';
|
||||
import 'package:focusflow_api/src/modules/streaks/streak_repository.dart';
|
||||
import 'package:focusflow_api/src/modules/streaks/streak_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/streaks/streak_service.dart';
|
||||
import 'package:focusflow_api/src/modules/sync/sync_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/sync/sync_service.dart';
|
||||
import 'package:focusflow_api/src/modules/tasks/dopamine_scorer.dart';
|
||||
import 'package:focusflow_api/src/modules/tasks/task_repository.dart';
|
||||
import 'package:focusflow_api/src/modules/tasks/task_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/tasks/task_service.dart';
|
||||
import 'package:focusflow_api/src/modules/time/time_routes.dart';
|
||||
import 'package:focusflow_api/src/modules/time/time_service.dart';
|
||||
|
||||
final _log = Logger('Server');
|
||||
|
||||
Future<void> main() async {
|
||||
// ── Logging ─────────────────────────────────────────────────────────
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'${record.time} [${record.level.name}] ${record.loggerName}: '
|
||||
'${record.message}',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Environment ─────────────────────────────────────────────────────
|
||||
Env.init();
|
||||
_log.info('Starting FocusFlow API (${Env.appEnv})');
|
||||
|
||||
// ── Database ────────────────────────────────────────────────────────
|
||||
await Database.init();
|
||||
_log.info('Database pool opened');
|
||||
|
||||
// ── Services (dependency injection) ─────────────────────────────────
|
||||
final tokenService = TokenService();
|
||||
final passwordHasher = PasswordHasher();
|
||||
final authService = AuthService(
|
||||
hasher: passwordHasher,
|
||||
tokenService: tokenService,
|
||||
);
|
||||
|
||||
final rewardEngine = RewardEngine();
|
||||
final dopamineScorer = DopamineScorer();
|
||||
final taskRepository = TaskRepository();
|
||||
final taskService = TaskService(
|
||||
repository: taskRepository,
|
||||
scorer: dopamineScorer,
|
||||
rewardEngine: rewardEngine,
|
||||
);
|
||||
|
||||
final streakRepository = StreakRepository();
|
||||
final streakService = StreakService(repository: streakRepository);
|
||||
|
||||
final rewardService = RewardService(engine: rewardEngine);
|
||||
final timeService = TimeService();
|
||||
final syncService = SyncService();
|
||||
final roomService = RoomService();
|
||||
|
||||
// ── Route modules ───────────────────────────────────────────────────
|
||||
final authRoutes = AuthRoutes(authService);
|
||||
final taskRoutes = TaskRoutes(taskService);
|
||||
final streakRoutes = StreakRoutes(streakService);
|
||||
final rewardRoutes = RewardRoutes(rewardService);
|
||||
final timeRoutes = TimeRoutes(timeService);
|
||||
final syncRoutes = SyncRoutes(syncService);
|
||||
final roomRoutes = RoomRoutes(roomService);
|
||||
|
||||
// ── Router ──────────────────────────────────────────────────────────
|
||||
final app = Router();
|
||||
|
||||
// Health check (public)
|
||||
app.get('/health', (Request request) {
|
||||
return Response.ok(
|
||||
jsonEncode({'status': 'ok'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// Mount module routers
|
||||
app.mount('/api/v1/auth/', authRoutes.router.call);
|
||||
app.mount('/api/v1/tasks/', taskRoutes.router.call);
|
||||
app.mount('/api/v1/streaks/', streakRoutes.router.call);
|
||||
app.mount('/api/v1/rewards/', rewardRoutes.router.call);
|
||||
app.mount('/api/v1/time/', timeRoutes.router.call);
|
||||
app.mount('/api/v1/sync/', syncRoutes.router.call);
|
||||
app.mount('/api/v1/rooms/', roomRoutes.router.call);
|
||||
|
||||
// ── Pipeline ────────────────────────────────────────────────────────
|
||||
final handler = const Pipeline()
|
||||
.addMiddleware(corsMiddleware())
|
||||
.addMiddleware(loggingMiddleware())
|
||||
.addMiddleware(errorHandlerMiddleware())
|
||||
.addMiddleware(rateLimitMiddleware())
|
||||
.addMiddleware(authMiddleware(tokenService))
|
||||
.addHandler(app.call);
|
||||
|
||||
// ── Start server ────────────────────────────────────────────────────
|
||||
final port = Env.port;
|
||||
final server =
|
||||
await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
|
||||
_log.info('Listening on http://${server.address.host}:${server.port}');
|
||||
|
||||
// ── Graceful shutdown ───────────────────────────────────────────────
|
||||
late final StreamSubscription<ProcessSignal> sigintSub;
|
||||
late final StreamSubscription<ProcessSignal> sigtermSub;
|
||||
|
||||
Future<void> shutdown() async {
|
||||
_log.info('Shutting down...');
|
||||
await server.close(force: false);
|
||||
await Database.close();
|
||||
_log.info('Server stopped');
|
||||
await sigintSub.cancel();
|
||||
await sigtermSub.cancel();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
sigintSub = ProcessSignal.sigint.watch().listen((_) => shutdown());
|
||||
sigtermSub = ProcessSignal.sigterm.watch().listen((_) => shutdown());
|
||||
}
|
||||
Reference in New Issue
Block a user