Initial commit: CleanPlate backend API
Dart Shelf REST API with auth, recipes, AI (Claude), search, and community modules. PostgreSQL, Redis, Meilisearch. Docker Compose for local dev.
This commit is contained in:
145
bin/migrate.dart
Normal file
145
bin/migrate.dart
Normal file
@@ -0,0 +1,145 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:cleanplate_api/src/config/env.dart';
|
||||
import 'package:cleanplate_api/src/config/database.dart';
|
||||
|
||||
final _log = Logger('Migrate');
|
||||
|
||||
/// Database migration runner.
|
||||
///
|
||||
/// Run with: dart run bin/migrate.dart
|
||||
///
|
||||
/// This applies all migrations in order. Each migration is idempotent
|
||||
/// (uses IF NOT EXISTS) so it is safe to re-run.
|
||||
void main(List<String> args) async {
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
stderr.writeln(
|
||||
'${record.time} [${record.level.name}] ${record.loggerName}: ${record.message}');
|
||||
});
|
||||
|
||||
_log.info('Starting migrations (env=${Env.environment})...');
|
||||
|
||||
await Database.initialize();
|
||||
|
||||
try {
|
||||
for (var i = 0; i < _migrations.length; i++) {
|
||||
_log.info('Running migration ${i + 1}/${_migrations.length}: ${_migrationNames[i]}');
|
||||
await Database.execute(_migrations[i]);
|
||||
}
|
||||
|
||||
_log.info('All migrations applied successfully');
|
||||
} catch (e, st) {
|
||||
_log.severe('Migration failed', e, st);
|
||||
exit(1);
|
||||
} finally {
|
||||
await Database.close();
|
||||
}
|
||||
}
|
||||
|
||||
const _migrationNames = [
|
||||
'Create users table',
|
||||
'Create refresh_tokens table',
|
||||
'Create recipes table',
|
||||
'Create ingredients table',
|
||||
'Create steps table',
|
||||
'Create saved_recipes table',
|
||||
'Create reviews table',
|
||||
];
|
||||
|
||||
const _migrations = [
|
||||
// 1. Users
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
bio TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
)
|
||||
''',
|
||||
|
||||
// 2. Refresh tokens
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
''',
|
||||
|
||||
// 3. Recipes
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
prep_time INTEGER,
|
||||
cook_time INTEGER,
|
||||
servings INTEGER,
|
||||
difficulty TEXT,
|
||||
cuisine TEXT,
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
image_url TEXT,
|
||||
is_ai_generated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
)
|
||||
''',
|
||||
|
||||
// 4. Ingredients
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS ingredients (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
unit TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
''',
|
||||
|
||||
// 5. Steps
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
step_number INTEGER NOT NULL,
|
||||
instruction TEXT NOT NULL,
|
||||
duration_minutes INTEGER
|
||||
)
|
||||
''',
|
||||
|
||||
// 6. Saved recipes (bookmarks)
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS saved_recipes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, recipe_id)
|
||||
)
|
||||
''',
|
||||
|
||||
// 7. Reviews
|
||||
'''
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
comment TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
''',
|
||||
];
|
||||
147
bin/server.dart
Normal file
147
bin/server.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
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:cleanplate_api/src/config/env.dart';
|
||||
import 'package:cleanplate_api/src/config/database.dart';
|
||||
import 'package:cleanplate_api/src/config/redis_config.dart';
|
||||
|
||||
import 'package:cleanplate_api/src/middleware/auth_middleware.dart';
|
||||
import 'package:cleanplate_api/src/middleware/cors_middleware.dart';
|
||||
import 'package:cleanplate_api/src/middleware/logging_middleware.dart';
|
||||
import 'package:cleanplate_api/src/middleware/error_handler.dart';
|
||||
import 'package:cleanplate_api/src/middleware/rate_limit_middleware.dart';
|
||||
|
||||
import 'package:cleanplate_api/src/modules/auth/auth_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/recipes/recipe_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/ai/ai_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/search/search_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/media/media_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/users/user_routes.dart';
|
||||
import 'package:cleanplate_api/src/modules/community/community_routes.dart';
|
||||
|
||||
final _log = Logger('Server');
|
||||
|
||||
void main(List<String> args) async {
|
||||
// ---------------------------------------------------------------
|
||||
// Logging
|
||||
// ---------------------------------------------------------------
|
||||
Logger.root.level = Env.isProduction ? Level.INFO : Level.ALL;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
stderr.writeln(
|
||||
'${record.time} [${record.level.name}] ${record.loggerName}: ${record.message}');
|
||||
if (record.error != null) stderr.writeln(' Error: ${record.error}');
|
||||
if (record.stackTrace != null) {
|
||||
stderr.writeln(' Stack: ${record.stackTrace}');
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Infrastructure connections
|
||||
// ---------------------------------------------------------------
|
||||
try {
|
||||
await Database.initialize();
|
||||
_log.info('Database connected');
|
||||
} catch (e) {
|
||||
_log.warning('Could not connect to database: $e');
|
||||
_log.warning('Server will start but database-dependent routes will fail');
|
||||
}
|
||||
|
||||
try {
|
||||
await RedisConfig.initialize();
|
||||
_log.info('Redis connected');
|
||||
} catch (e) {
|
||||
_log.warning('Could not connect to Redis: $e');
|
||||
_log.warning('Server will start but rate-limiting may use in-memory fallback');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Routers
|
||||
// ---------------------------------------------------------------
|
||||
final authRoutes = AuthRoutes();
|
||||
final recipeRoutes = RecipeRoutes();
|
||||
final aiRoutes = AiRoutes();
|
||||
final searchRoutes = SearchRoutes();
|
||||
final mediaRoutes = MediaRoutes();
|
||||
final userRoutes = UserRoutes();
|
||||
final communityRoutes = CommunityRoutes();
|
||||
|
||||
// Top-level router.
|
||||
final app = Router();
|
||||
|
||||
// Health check.
|
||||
app.get('/health', (Request request) {
|
||||
return Response.ok(
|
||||
jsonEncode({'status': 'ok'}),
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
|
||||
// Mount module routers under /api/v1.
|
||||
app.mount('/api/v1/auth/', authRoutes.router.call);
|
||||
app.mount('/api/v1/recipes/', recipeRoutes.router.call);
|
||||
app.mount('/api/v1/ai/', aiRoutes.router.call);
|
||||
app.mount('/api/v1/search/', searchRoutes.router.call);
|
||||
app.mount('/api/v1/media/', mediaRoutes.router.call);
|
||||
app.mount('/api/v1/users/', userRoutes.router.call);
|
||||
|
||||
// Community reviews are nested under a recipe.
|
||||
// We use a small adapter to inject the recipeId into the request context.
|
||||
app.mount('/api/v1/recipes/<recipeId>/reviews/', (Request request) {
|
||||
final recipeId = request.params['recipeId'];
|
||||
final updated = request.change(context: {'recipeId': recipeId});
|
||||
return communityRoutes.router.call(updated);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Middleware pipeline
|
||||
// ---------------------------------------------------------------
|
||||
final handler = Pipeline()
|
||||
.addMiddleware(corsMiddleware())
|
||||
.addMiddleware(loggingMiddleware())
|
||||
.addMiddleware(errorHandler())
|
||||
.addMiddleware(rateLimitMiddleware())
|
||||
.addMiddleware(authMiddleware())
|
||||
.addHandler(app.call);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Start server
|
||||
// ---------------------------------------------------------------
|
||||
final ip = InternetAddress.anyIPv4;
|
||||
final port = int.parse(Env.port);
|
||||
final server = await shelf_io.serve(handler, ip, port);
|
||||
|
||||
_log.info('CleanPlate API listening on http://${server.address.host}:${server.port}');
|
||||
_log.info('Environment: ${Env.environment}');
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Graceful shutdown
|
||||
// ---------------------------------------------------------------
|
||||
ProcessSignal.sigint.watch().listen((_) async {
|
||||
_log.info('SIGINT received — shutting down...');
|
||||
await _shutdown(server);
|
||||
});
|
||||
|
||||
// SIGTERM is not available on Windows but fine on Linux/macOS.
|
||||
if (!Platform.isWindows) {
|
||||
ProcessSignal.sigterm.watch().listen((_) async {
|
||||
_log.info('SIGTERM received — shutting down...');
|
||||
await _shutdown(server);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _shutdown(HttpServer server) async {
|
||||
_log.info('Closing HTTP server...');
|
||||
await server.close(force: false);
|
||||
_log.info('Closing database pool...');
|
||||
await Database.close();
|
||||
_log.info('Closing Redis connection...');
|
||||
await RedisConfig.close();
|
||||
_log.info('Shutdown complete');
|
||||
exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user