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:
Oracle Public Cloud User
2026-03-04 14:52:13 +00:00
commit 6bd1ab7e9f
43 changed files with 4216 additions and 0 deletions

145
bin/migrate.dart Normal file
View 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
View 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);
}