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

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.dockerignore
Dockerfile
build/
.dart_tool/
.git/
.github/
.gitignore
.idea/
.packages

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# CleanPlate API Environment Variables
# Server
PORT=8080
APP_ENV=development
# Database
DATABASE_URL=postgresql://cleanplate:cleanplate@localhost:5432/cleanplate
# Redis
REDIS_URL=redis://localhost:6379
# Authentication
JWT_SECRET=change-this-to-a-secure-random-string-in-production
# Anthropic Claude API
ANTHROPIC_API_KEY=sk-ant-your-key-here
# Meilisearch
MEILISEARCH_URL=http://localhost:7700
MEILI_MASTER_KEY=change-this-in-production
# MinIO (S3-compatible storage)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/

3
CHANGELOG.md Normal file
View File

@@ -0,0 +1,3 @@
## 1.0.0
- Initial version.

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Use latest stable channel SDK.
FROM dart:stable AS build
# Resolve app dependencies.
WORKDIR /app
COPY pubspec.* ./
RUN dart pub get
# Copy app source code (except anything in .dockerignore) and AOT compile app.
COPY . .
RUN dart compile exe bin/server.dart -o bin/server
# Build minimal serving image from AOT-compiled `/server`
# and the pre-built AOT-runtime in the `/runtime/` directory of the base image.
FROM scratch
COPY --from=build /runtime/ /
COPY --from=build /app/bin/server /app/bin/
# Start server.
EXPOSE 8080
CMD ["/app/bin/server"]

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
A server app built using [Shelf](https://pub.dev/packages/shelf),
configured to enable running with [Docker](https://www.docker.com/).
This sample code handles HTTP GET requests to `/` and `/echo/<message>`
# Running the sample
## Running with the Dart SDK
You can run the example with the [Dart SDK](https://dart.dev/get-dart)
like this:
```
$ dart run bin/server.dart
Server listening on port 8080
```
And then from a second terminal:
```
$ curl http://0.0.0.0:8080
Hello, World!
$ curl http://0.0.0.0:8080/echo/I_love_Dart
I_love_Dart
```
## Running with Docker
If you have [Docker Desktop](https://www.docker.com/get-started) installed, you
can build and run with the `docker` command:
```
$ docker build . -t myserver
$ docker run -it -p 8080:8080 myserver
Server listening on port 8080
```
And then from a second terminal:
```
$ curl http://0.0.0.0:8080
Hello, World!
$ curl http://0.0.0.0:8080/echo/I_love_Dart
I_love_Dart
```
You should see the logging printed in the first terminal:
```
2021-05-06T15:47:04.620417 0:00:00.000158 GET [200] /
2021-05-06T15:47:08.392928 0:00:00.001216 GET [200] /echo/I_love_Dart
```

30
analysis_options.yaml Normal file
View File

@@ -0,0 +1,30 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options

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);
}

114
docker-compose.yml Normal file
View File

@@ -0,0 +1,114 @@
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
- DATABASE_URL=postgresql://cleanplate:cleanplate@postgres:5432/cleanplate
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-production}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- MEILISEARCH_URL=http://meilisearch:7700
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-dev-meili-key}
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- APP_ENV=development
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
meilisearch:
condition: service_started
minio:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: cleanplate
POSTGRES_USER: cleanplate
POSTGRES_PASSWORD: cleanplate
volumes:
- pg_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U cleanplate -d cleanplate"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
meilisearch:
image: getmeili/meilisearch:v1.11
ports:
- "7700:7700"
environment:
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-key}
MEILI_ENV: development
volumes:
- meili_data:/meili_data
restart: unless-stopped
minio:
image: minio/minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
restart: unless-stopped
# MinIO bucket initialization
minio-init:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "
sleep 5;
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing local/cleanplate-images;
mc mb --ignore-existing local/cleanplate-videos;
mc mb --ignore-existing local/cleanplate-temp;
mc anonymous set download local/cleanplate-images;
mc anonymous set download local/cleanplate-videos;
echo 'Buckets created successfully';
"
volumes:
pg_data:
redis_data:
meili_data:
minio_data:

View File

@@ -0,0 +1,86 @@
import 'package:postgres/postgres.dart';
import 'package:logging/logging.dart';
import 'env.dart';
final _log = Logger('Database');
class Database {
static Pool? _pool;
/// Initialize the connection pool from the DATABASE_URL environment variable.
static Future<void> initialize() async {
if (_pool != null) return;
final uri = Uri.parse(Env.databaseUrl);
final endpoint = Endpoint(
host: uri.host,
port: uri.port,
database: uri.pathSegments.isNotEmpty ? uri.pathSegments.first : 'cleanplate',
username: uri.userInfo.contains(':')
? uri.userInfo.split(':').first
: uri.userInfo,
password: uri.userInfo.contains(':')
? uri.userInfo.split(':').last
: null,
);
_pool = Pool.withEndpoints(
[endpoint],
settings: PoolSettings(
maxConnectionCount: 10,
sslMode: SslMode.disable,
),
);
_log.info('Database pool initialized (host=${uri.host}, db=${uri.pathSegments.isNotEmpty ? uri.pathSegments.first : "cleanplate"})');
}
/// Execute a statement that does not return rows (INSERT, UPDATE, DELETE, DDL).
static Future<Result> execute(
String sql, {
Map<String, dynamic>? parameters,
}) async {
_ensureInitialized();
return _pool!.execute(
Sql.named(sql),
parameters: parameters ?? {},
);
}
/// Execute a query and return the result rows.
static Future<Result> query(
String sql, {
Map<String, dynamic>? parameters,
}) async {
_ensureInitialized();
return _pool!.execute(
Sql.named(sql),
parameters: parameters ?? {},
);
}
/// Run multiple statements inside a single transaction.
static Future<T> transaction<T>(
Future<T> Function(TxSession tx) action,
) async {
_ensureInitialized();
return _pool!.runTx(action);
}
/// Close all connections in the pool.
static Future<void> close() async {
if (_pool != null) {
await _pool!.close();
_pool = null;
_log.info('Database pool closed');
}
}
static void _ensureInitialized() {
if (_pool == null) {
throw StateError(
'Database not initialized. Call Database.initialize() first.',
);
}
}
}

27
lib/src/config/env.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'dart:io';
class Env {
static String get port => Platform.environment['PORT'] ?? '8080';
static String get databaseUrl =>
Platform.environment['DATABASE_URL'] ??
'postgresql://cleanplate:cleanplate@localhost:5432/cleanplate';
static String get redisUrl =>
Platform.environment['REDIS_URL'] ?? 'redis://localhost:6379';
static String get jwtSecret =>
Platform.environment['JWT_SECRET'] ?? 'dev-secret-change-in-production';
static String get anthropicApiKey =>
Platform.environment['ANTHROPIC_API_KEY'] ?? '';
static String get meilisearchUrl =>
Platform.environment['MEILISEARCH_URL'] ?? 'http://localhost:7700';
static String get meilisearchKey =>
Platform.environment['MEILI_MASTER_KEY'] ?? '';
static String get minioEndpoint =>
Platform.environment['MINIO_ENDPOINT'] ?? 'localhost:9000';
static String get minioAccessKey =>
Platform.environment['MINIO_ACCESS_KEY'] ?? 'minioadmin';
static String get minioSecretKey =>
Platform.environment['MINIO_SECRET_KEY'] ?? 'minioadmin';
static String get environment =>
Platform.environment['APP_ENV'] ?? 'development';
static bool get isProduction => environment == 'production';
}

View File

@@ -0,0 +1,43 @@
import 'package:redis/redis.dart';
import 'package:logging/logging.dart';
import 'env.dart';
final _log = Logger('RedisConfig');
class RedisConfig {
static RedisConnection? _connection;
static Command? _command;
/// Connect to Redis using the REDIS_URL environment variable.
static Future<void> initialize() async {
if (_command != null) return;
final uri = Uri.parse(Env.redisUrl);
final host = uri.host.isNotEmpty ? uri.host : 'localhost';
final port = uri.port != 0 ? uri.port : 6379;
_connection = RedisConnection();
_command = await _connection!.connect(host, port);
_log.info('Redis connected ($host:$port)');
}
/// Return the active Redis command interface.
static Command get command {
if (_command == null) {
throw StateError(
'Redis not initialized. Call RedisConfig.initialize() first.',
);
}
return _command!;
}
/// Close the Redis connection.
static Future<void> close() async {
if (_connection != null) {
await _connection!.close();
_connection = null;
_command = null;
_log.info('Redis connection closed');
}
}
}

View File

@@ -0,0 +1,81 @@
import 'package:shelf/shelf.dart';
import '../modules/auth/token_service.dart';
import '../shared/api_response.dart';
/// Routes that do not require authentication.
const _publicRoutes = <_RoutePattern>[
_RoutePattern('POST', '/api/v1/auth/register'),
_RoutePattern('POST', '/api/v1/auth/login'),
_RoutePattern('POST', '/api/v1/auth/refresh'),
_RoutePattern('GET', '/health'),
];
/// A lightweight path-prefix check so GET /api/v1/recipes and
/// GET /api/v1/recipes/`<id>` are both public.
const _publicPrefixes = <_PrefixPattern>[
_PrefixPattern('GET', '/api/v1/recipes'),
_PrefixPattern('GET', '/api/v1/search'),
];
class _RoutePattern {
final String method;
final String path;
const _RoutePattern(this.method, this.path);
}
class _PrefixPattern {
final String method;
final String prefix;
const _PrefixPattern(this.method, this.prefix);
}
/// Shelf middleware that verifies a JWT Bearer token on protected routes.
///
/// On success, the userId is stored in the request context under the key
/// `userId` so downstream handlers can access it via
/// `request.context['userId']`.
Middleware authMiddleware() {
return (Handler innerHandler) {
return (Request request) async {
final method = request.method;
final path = '/${request.url.path}';
// Check exact public routes.
for (final route in _publicRoutes) {
if (route.method == method && route.path == path) {
return innerHandler(request);
}
}
// Check public prefixes.
for (final prefix in _publicPrefixes) {
if (prefix.method == method && path.startsWith(prefix.prefix)) {
return innerHandler(request);
}
}
// Extract Bearer token.
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return ApiResponse.unauthorized('Missing or malformed Authorization header');
}
final token = authHeader.substring(7);
try {
final userId = TokenService.verifyAccessToken(token);
if (userId == null) {
return ApiResponse.unauthorized('Invalid or expired token');
}
// Add userId to request context.
final updatedRequest = request.change(
context: {'userId': userId},
);
return innerHandler(updatedRequest);
} catch (_) {
return ApiResponse.unauthorized('Invalid or expired token');
}
};
};
}

View File

@@ -0,0 +1,15 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
/// Returns a CORS middleware with permissive defaults suitable for development.
Middleware corsMiddleware() {
return corsHeaders(
headers: {
ACCESS_CONTROL_ALLOW_ORIGIN: '*',
ACCESS_CONTROL_ALLOW_METHODS: 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
ACCESS_CONTROL_ALLOW_HEADERS:
'Origin, Content-Type, Accept, Authorization, X-Requested-With',
ACCESS_CONTROL_MAX_AGE: '86400',
},
);
}

View File

@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:logging/logging.dart';
final _log = Logger('ErrorHandler');
/// Global error-handling middleware.
///
/// Catches synchronous and asynchronous exceptions thrown by inner handlers
/// and converts them to structured JSON error responses.
Middleware errorHandler() {
return (Handler innerHandler) {
return (Request request) async {
try {
final response = await innerHandler(request);
return response;
} on ApiException catch (e) {
_log.warning('API error: ${e.code} - ${e.message}');
return Response(e.statusCode,
headers: {'content-type': 'application/json'},
body: jsonEncode({
'status': 'error',
'error': {
'code': e.code,
'message': e.message,
if (e.details != null) 'details': e.details,
},
}));
} on FormatException catch (e) {
_log.warning('Bad request: $e');
return Response(400,
headers: {'content-type': 'application/json'},
body: jsonEncode({
'status': 'error',
'error': {
'code': 'BAD_REQUEST',
'message': 'Invalid request format: ${e.message}',
},
}));
} catch (e, st) {
_log.severe('Unhandled exception', e, st);
return Response.internalServerError(
headers: {'content-type': 'application/json'},
body: jsonEncode({
'status': 'error',
'error': {
'code': 'INTERNAL_ERROR',
'message': 'Internal server error',
},
}),
);
}
};
};
}
/// A typed exception that the error handler can inspect to produce a proper
/// HTTP status and machine-readable error code.
class ApiException implements Exception {
final int statusCode;
final String code;
final String message;
final dynamic details;
ApiException(this.statusCode, this.code, this.message, {this.details});
factory ApiException.badRequest(String message, {dynamic details}) =>
ApiException(400, 'BAD_REQUEST', message, details: details);
factory ApiException.unauthorized([String message = 'Unauthorized']) =>
ApiException(401, 'UNAUTHORIZED', message);
factory ApiException.forbidden([String message = 'Forbidden']) =>
ApiException(403, 'FORBIDDEN', message);
factory ApiException.notFound([String message = 'Not found']) =>
ApiException(404, 'NOT_FOUND', message);
factory ApiException.conflict(String message) =>
ApiException(409, 'CONFLICT', message);
factory ApiException.tooManyRequests([String message = 'Too many requests']) =>
ApiException(429, 'TOO_MANY_REQUESTS', message);
factory ApiException.internal([String message = 'Internal server error']) =>
ApiException(500, 'INTERNAL_ERROR', message);
@override
String toString() => 'ApiException($statusCode, $code, $message)';
}

View File

@@ -0,0 +1,35 @@
import 'package:shelf/shelf.dart';
import 'package:logging/logging.dart';
final _log = Logger('HTTP');
/// Shelf middleware that logs incoming requests and outgoing responses with
/// timing information.
Middleware loggingMiddleware() {
return (Handler innerHandler) {
return (Request request) async {
final stopwatch = Stopwatch()..start();
final method = request.method;
final path = request.requestedUri.path;
_log.info('--> $method $path');
try {
final response = await innerHandler(request);
stopwatch.stop();
_log.info(
'<-- $method $path ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)',
);
return response;
} catch (e, st) {
stopwatch.stop();
_log.severe(
'<-- $method $path ERROR (${stopwatch.elapsedMilliseconds}ms)',
e,
st,
);
rethrow;
}
};
};
}

View File

@@ -0,0 +1,64 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:logging/logging.dart';
final _log = Logger('RateLimit');
/// Simple in-memory rate limiter.
///
/// Tracks request counts per IP address with a sliding window. In production
/// this should be backed by Redis for multi-instance consistency.
Middleware rateLimitMiddleware({
int maxRequests = 100,
Duration window = const Duration(minutes: 1),
}) {
final buckets = <String, _Bucket>{};
return (Handler innerHandler) {
return (Request request) async {
final ip = request.headers['x-forwarded-for']?.split(',').first.trim() ??
request.headers['x-real-ip'] ??
'unknown';
final now = DateTime.now();
final bucket = buckets.putIfAbsent(ip, () => _Bucket(now));
// Reset if the window has elapsed.
if (now.difference(bucket.windowStart) > window) {
bucket.windowStart = now;
bucket.count = 0;
}
bucket.count++;
if (bucket.count > maxRequests) {
_log.warning('Rate limit exceeded for $ip');
return Response(429,
headers: {
'content-type': 'application/json',
'retry-after': window.inSeconds.toString(),
},
body: jsonEncode({
'status': 'error',
'error': {
'code': 'TOO_MANY_REQUESTS',
'message': 'Rate limit exceeded. Try again later.',
},
}));
}
final remaining = maxRequests - bucket.count;
final response = await innerHandler(request);
return response.change(headers: {
'x-ratelimit-limit': '$maxRequests',
'x-ratelimit-remaining': '$remaining',
});
};
};
}
class _Bucket {
DateTime windowStart;
int count = 0;
_Bucket(this.windowStart);
}

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import 'ai_service.dart';
class AiRoutes {
Router get router {
final router = Router();
router.post('/generate', _generateRecipe);
return router;
}
Future<Response> _generateRecipe(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final body =
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final ingredients =
(body['ingredients'] as List<dynamic>?)?.cast<String>() ?? [];
final dietaryRestrictions =
(body['dietary_restrictions'] as List<dynamic>?)?.cast<String>();
final allergies =
(body['allergies'] as List<dynamic>?)?.cast<String>();
final mood = body['mood'] as String?;
final maxTimeMinutes = body['max_time_minutes'] as int?;
final servings = body['servings'] as int?;
final skillLevel = body['skill_level'] as String?;
final cuisine = body['cuisine'] as String?;
final recipe = await AiService.generateRecipe(
ingredients: ingredients,
dietaryRestrictions: dietaryRestrictions,
allergies: allergies,
mood: mood,
maxTimeMinutes: maxTimeMinutes,
servings: servings,
skillLevel: skillLevel,
cuisine: cuisine,
);
return ApiResponse.success(recipe);
}
}

View File

@@ -0,0 +1,105 @@
import 'dart:convert';
import 'package:logging/logging.dart';
import '../../middleware/error_handler.dart';
import 'llm_client.dart';
import 'prompt_templates.dart';
final _log = Logger('AiService');
class AiService {
/// Generate a recipe from the given ingredients and preferences.
///
/// Calls the LLM, parses the structured JSON response, validates it, and
/// returns a recipe map ready to be persisted or returned to the client.
static Future<Map<String, dynamic>> generateRecipe({
required List<String> ingredients,
List<String>? dietaryRestrictions,
List<String>? allergies,
String? mood,
int? maxTimeMinutes,
int? servings,
String? skillLevel,
String? cuisine,
}) async {
if (ingredients.isEmpty) {
throw ApiException.badRequest(
'At least one ingredient is required to generate a recipe');
}
final prompt = PromptTemplates.recipeGeneration(
ingredients: ingredients,
dietaryRestrictions: dietaryRestrictions,
allergies: allergies,
mood: mood,
maxTimeMinutes: maxTimeMinutes,
servings: servings,
skillLevel: skillLevel,
cuisine: cuisine,
);
_log.info(
'Generating recipe for ${ingredients.length} ingredients');
final rawResponse = await LlmClient.complete(
prompt: prompt,
temperature: 0.7,
);
// The model may sometimes wrap JSON in markdown fences; strip them.
final cleaned = _stripMarkdownFences(rawResponse).trim();
Map<String, dynamic> recipe;
try {
recipe = jsonDecode(cleaned) as Map<String, dynamic>;
} on FormatException catch (e) {
_log.warning('Failed to parse LLM JSON output: $e');
throw ApiException.internal(
'AI returned invalid JSON. Please try again.');
}
// Basic validation.
if (recipe['title'] == null || (recipe['title'] as String).isEmpty) {
throw ApiException.internal('AI response missing recipe title');
}
if (recipe['ingredients'] == null ||
(recipe['ingredients'] as List).isEmpty) {
throw ApiException.internal('AI response missing ingredients');
}
if (recipe['steps'] == null || (recipe['steps'] as List).isEmpty) {
throw ApiException.internal('AI response missing steps');
}
// Normalise types.
recipe['prep_time'] = _toInt(recipe['prep_time']);
recipe['cook_time'] = _toInt(recipe['cook_time']);
recipe['servings'] = _toInt(recipe['servings']);
recipe['is_ai_generated'] = true;
recipe['tags'] =
(recipe['tags'] as List<dynamic>?)?.cast<String>() ?? <String>[];
_log.info('Recipe generated: ${recipe['title']}');
return recipe;
}
/// Remove ``` or ```json fences.
static String _stripMarkdownFences(String text) {
var s = text.trim();
if (s.startsWith('```')) {
final firstNewline = s.indexOf('\n');
if (firstNewline != -1) {
s = s.substring(firstNewline + 1);
}
}
if (s.endsWith('```')) {
s = s.substring(0, s.length - 3);
}
return s;
}
static int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is String) return int.tryParse(value);
return null;
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import '../../config/env.dart';
import '../../middleware/error_handler.dart';
final _log = Logger('LlmClient');
/// HTTP client wrapper for the Anthropic Claude Messages API.
class LlmClient {
static const _baseUrl = 'https://api.anthropic.com/v1/messages';
static const _anthropicVersion = '2023-06-01';
static const _defaultModel = 'claude-sonnet-4-20250514';
static const _defaultMaxTokens = 4096;
static const _timeout = Duration(seconds: 60);
/// Send a prompt to the Claude API and return the text response.
static Future<String> complete({
required String prompt,
String model = _defaultModel,
int maxTokens = _defaultMaxTokens,
double? temperature,
}) async {
final apiKey = Env.anthropicApiKey;
if (apiKey.isEmpty) {
throw ApiException.internal(
'Anthropic API key is not configured. Set ANTHROPIC_API_KEY.');
}
final requestBody = {
'model': model,
'max_tokens': maxTokens,
'messages': [
{'role': 'user', 'content': prompt},
],
if (temperature != null) 'temperature': temperature,
};
_log.info('Calling Claude API (model=$model, maxTokens=$maxTokens)');
final http.Response response;
try {
response = await http
.post(
Uri.parse(_baseUrl),
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': _anthropicVersion,
},
body: jsonEncode(requestBody),
)
.timeout(_timeout);
} on Exception catch (e) {
_log.severe('Claude API request failed', e);
throw ApiException.internal('Failed to reach AI service: $e');
}
if (response.statusCode != 200) {
_log.severe(
'Claude API error: ${response.statusCode} ${response.body}');
throw ApiException(
502,
'AI_SERVICE_ERROR',
'AI service returned status ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final content = json['content'] as List<dynamic>?;
if (content == null || content.isEmpty) {
throw ApiException.internal('Empty response from AI service');
}
final textBlock = content.firstWhere(
(block) => (block as Map<String, dynamic>)['type'] == 'text',
orElse: () => null,
);
if (textBlock == null) {
throw ApiException.internal('No text block in AI response');
}
return (textBlock as Map<String, dynamic>)['text'] as String;
}
}

View File

@@ -0,0 +1,99 @@
/// Prompt templates for the AI recipe generation feature.
class PromptTemplates {
/// Build a recipe-generation prompt from user inputs.
///
/// The prompt instructs the LLM to return a JSON object that conforms to a
/// strict schema so we can parse it programmatically.
static String recipeGeneration({
required List<String> ingredients,
List<String>? dietaryRestrictions,
List<String>? allergies,
String? mood,
int? maxTimeMinutes,
int? servings,
String? skillLevel,
String? cuisine,
}) {
final buffer = StringBuffer();
buffer.writeln('You are a professional chef and recipe developer.');
buffer.writeln('Create a detailed, original recipe based on the following requirements.');
buffer.writeln();
buffer.writeln('## Available Ingredients');
buffer.writeln(ingredients.map((i) => '- $i').join('\n'));
buffer.writeln();
if (dietaryRestrictions != null && dietaryRestrictions.isNotEmpty) {
buffer.writeln('## Dietary Restrictions');
buffer.writeln(dietaryRestrictions.map((d) => '- $d').join('\n'));
buffer.writeln();
}
if (allergies != null && allergies.isNotEmpty) {
buffer.writeln('## Allergies (MUST avoid these ingredients entirely)');
buffer.writeln(allergies.map((a) => '- $a').join('\n'));
buffer.writeln();
}
if (mood != null) {
buffer.writeln('## Mood / Vibe');
buffer.writeln(mood);
buffer.writeln();
}
if (maxTimeMinutes != null) {
buffer.writeln('## Maximum Total Time');
buffer.writeln('$maxTimeMinutes minutes (prep + cook combined)');
buffer.writeln();
}
if (servings != null) {
buffer.writeln('## Servings');
buffer.writeln('$servings');
buffer.writeln();
}
if (skillLevel != null) {
buffer.writeln('## Skill Level');
buffer.writeln(skillLevel);
buffer.writeln();
}
if (cuisine != null) {
buffer.writeln('## Preferred Cuisine');
buffer.writeln(cuisine);
buffer.writeln();
}
buffer.writeln('## Output Format');
buffer.writeln('Return ONLY valid JSON matching this schema (no markdown fences):');
buffer.writeln(_jsonSchema);
return buffer.toString();
}
static const _jsonSchema = '''
{
"title": "string",
"description": "string (1-2 sentences)",
"prep_time": "integer (minutes)",
"cook_time": "integer (minutes)",
"servings": "integer",
"difficulty": "easy | medium | hard",
"cuisine": "string",
"tags": ["string"],
"ingredients": [
{
"name": "string",
"quantity": "string",
"unit": "string"
}
],
"steps": [
{
"instruction": "string",
"duration_minutes": "integer or null"
}
]
}''';
}

View File

@@ -0,0 +1,90 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import 'auth_service.dart';
class AuthRoutes {
Router get router {
final router = Router();
// POST /register
router.post('/register', _register);
// POST /login
router.post('/login', _login);
// POST /refresh
router.post('/refresh', _refresh);
// POST /logout
router.post('/logout', _logout);
// DELETE /account
router.delete('/account', _deleteAccount);
return router;
}
Future<Response> _register(Request request) async {
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final email = body['email'] as String? ?? '';
final username = body['username'] as String? ?? '';
final password = body['password'] as String? ?? '';
final result = await AuthService.register(
email: email,
username: username,
password: password,
);
return ApiResponse.created(result);
}
Future<Response> _login(Request request) async {
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final email = body['email'] as String? ?? '';
final password = body['password'] as String? ?? '';
final result = await AuthService.login(
email: email,
password: password,
);
return ApiResponse.success(result);
}
Future<Response> _refresh(Request request) async {
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final refreshToken = body['refresh_token'] as String? ?? '';
if (refreshToken.isEmpty) {
return ApiResponse.error('BAD_REQUEST', 'refresh_token is required');
}
final result = await AuthService.refresh(refreshToken: refreshToken);
return ApiResponse.success(result);
}
Future<Response> _logout(Request request) async {
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final refreshToken = body['refresh_token'] as String? ?? '';
if (refreshToken.isEmpty) {
return ApiResponse.error('BAD_REQUEST', 'refresh_token is required');
}
await AuthService.logout(refreshToken: refreshToken);
return ApiResponse.noContent();
}
Future<Response> _deleteAccount(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) {
return ApiResponse.unauthorized();
}
await AuthService.deleteAccount(userId: userId);
return ApiResponse.noContent();
}
}

View File

@@ -0,0 +1,203 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import '../../config/database.dart';
import '../../middleware/error_handler.dart';
import 'password_hasher.dart';
import 'token_service.dart';
final _log = Logger('AuthService');
const _uuid = Uuid();
class AuthService {
/// Register a new user account.
///
/// Returns a map with `user`, `access_token`, and `refresh_token`.
static Future<Map<String, dynamic>> register({
required String email,
required String username,
required String password,
}) async {
// Validate input.
if (email.isEmpty || !email.contains('@')) {
throw ApiException.badRequest('Invalid email address');
}
if (username.isEmpty || username.length < 3) {
throw ApiException.badRequest('Username must be at least 3 characters');
}
if (password.length < 8) {
throw ApiException.badRequest('Password must be at least 8 characters');
}
// Check for existing user.
final existing = await Database.query(
'SELECT id FROM users WHERE email = @email OR username = @username LIMIT 1',
parameters: {'email': email, 'username': username},
);
if (existing.isNotEmpty) {
throw ApiException.conflict('A user with that email or username already exists');
}
final userId = _uuid.v4();
final passwordHash = PasswordHasher.hash(password);
final now = DateTime.now().toUtc();
await Database.execute(
'''INSERT INTO users (id, email, username, password_hash, created_at, updated_at)
VALUES (@id, @email, @username, @passwordHash, @createdAt, @updatedAt)''',
parameters: {
'id': userId,
'email': email,
'username': username,
'passwordHash': passwordHash,
'createdAt': now,
'updatedAt': now,
},
);
// Generate token pair.
final accessToken = TokenService.createAccessToken(userId);
final refreshToken = TokenService.generateRefreshToken();
await _storeRefreshToken(userId, refreshToken);
_log.info('User registered: $userId ($email)');
return {
'user': {
'id': userId,
'email': email,
'username': username,
'created_at': now.toIso8601String(),
},
'access_token': accessToken,
'refresh_token': refreshToken,
};
}
/// Authenticate a user with email and password.
///
/// Returns a map with `user`, `access_token`, and `refresh_token`.
static Future<Map<String, dynamic>> login({
required String email,
required String password,
}) async {
final result = await Database.query(
'SELECT id, email, username, password_hash, created_at FROM users WHERE email = @email AND deleted_at IS NULL LIMIT 1',
parameters: {'email': email},
);
if (result.isEmpty) {
throw ApiException.unauthorized('Invalid email or password');
}
final row = result.first;
final storedHash = row[3] as String;
if (!PasswordHasher.verify(password, storedHash)) {
throw ApiException.unauthorized('Invalid email or password');
}
final userId = row[0] as String;
final accessToken = TokenService.createAccessToken(userId);
final refreshToken = TokenService.generateRefreshToken();
await _storeRefreshToken(userId, refreshToken);
_log.info('User logged in: $userId');
return {
'user': {
'id': userId,
'email': row[1] as String,
'username': row[2] as String,
'created_at': (row[4] as DateTime).toIso8601String(),
},
'access_token': accessToken,
'refresh_token': refreshToken,
};
}
/// Rotate a refresh token: validate the old one, revoke it, and issue a new
/// access + refresh pair.
static Future<Map<String, dynamic>> refresh({
required String refreshToken,
}) async {
final tokenHash = TokenService.hashRefreshToken(refreshToken);
final result = await Database.query(
'''SELECT id, user_id FROM refresh_tokens
WHERE token_hash = @tokenHash AND revoked = false AND expires_at > NOW()
LIMIT 1''',
parameters: {'tokenHash': tokenHash},
);
if (result.isEmpty) {
throw ApiException.unauthorized('Invalid or expired refresh token');
}
final tokenId = result.first[0] as String;
final userId = result.first[1] as String;
// Revoke old token.
await Database.execute(
'UPDATE refresh_tokens SET revoked = true WHERE id = @id',
parameters: {'id': tokenId},
);
// Issue new pair.
final newAccessToken = TokenService.createAccessToken(userId);
final newRefreshToken = TokenService.generateRefreshToken();
await _storeRefreshToken(userId, newRefreshToken);
_log.info('Tokens refreshed for user: $userId');
return {
'access_token': newAccessToken,
'refresh_token': newRefreshToken,
};
}
/// Revoke a refresh token (logout).
static Future<void> logout({required String refreshToken}) async {
final tokenHash = TokenService.hashRefreshToken(refreshToken);
await Database.execute(
'UPDATE refresh_tokens SET revoked = true WHERE token_hash = @tokenHash',
parameters: {'tokenHash': tokenHash},
);
_log.info('Refresh token revoked');
}
/// Soft-delete a user account.
static Future<void> deleteAccount({required String userId}) async {
await Database.execute(
'UPDATE users SET deleted_at = NOW() WHERE id = @id',
parameters: {'id': userId},
);
// Revoke all refresh tokens for the user.
await Database.execute(
'UPDATE refresh_tokens SET revoked = true WHERE user_id = @userId',
parameters: {'userId': userId},
);
_log.info('Account deleted (soft): $userId');
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
static Future<void> _storeRefreshToken(
String userId, String refreshToken) async {
final tokenHash = TokenService.hashRefreshToken(refreshToken);
final id = _uuid.v4();
final expiresAt = DateTime.now().toUtc().add(const Duration(days: 30));
await Database.execute(
'''INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked)
VALUES (@id, @userId, @tokenHash, @expiresAt, false)''',
parameters: {
'id': id,
'userId': userId,
'tokenHash': tokenHash,
'expiresAt': expiresAt,
},
);
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
/// PBKDF2-SHA256 password hasher.
///
/// Stores passwords as `<iterations>:<base64-salt>:<base64-hash>`.
class PasswordHasher {
static const int _iterations = 100000;
static const int _keyLength = 32; // 256 bits
static const int _saltLength = 32; // 256-bit salt
/// Hash a plaintext [password] and return the encoded string.
static String hash(String password) {
final salt = _generateSalt();
final hash = _pbkdf2(password, salt, _iterations, _keyLength);
final saltB64 = base64Encode(salt);
final hashB64 = base64Encode(hash);
return '$_iterations:$saltB64:$hashB64';
}
/// Verify a plaintext [password] against a previously [encoded] hash.
static bool verify(String password, String encoded) {
final parts = encoded.split(':');
if (parts.length != 3) return false;
final iterations = int.tryParse(parts[0]);
if (iterations == null) return false;
final salt = base64Decode(parts[1]);
final storedHash = base64Decode(parts[2]);
final computedHash =
_pbkdf2(password, salt, iterations, storedHash.length);
// Constant-time comparison.
if (computedHash.length != storedHash.length) return false;
var result = 0;
for (var i = 0; i < computedHash.length; i++) {
result |= computedHash[i] ^ storedHash[i];
}
return result == 0;
}
/// Generate a random salt.
static Uint8List _generateSalt() {
final random = Random.secure();
return Uint8List.fromList(
List<int>.generate(_saltLength, (_) => random.nextInt(256)));
}
/// Pure-Dart PBKDF2 with HMAC-SHA256.
static Uint8List _pbkdf2(
String password, List<int> salt, int iterations, int keyLength) {
final passwordBytes = utf8.encode(password);
final hmacTemplate = Hmac(sha256, passwordBytes);
final blocks = (keyLength / 32).ceil();
final result = BytesBuilder();
for (var blockIndex = 1; blockIndex <= blocks; blockIndex++) {
// U_1 = HMAC(password, salt || INT_32_BE(blockIndex))
final saltWithIndex = Uint8List(salt.length + 4);
saltWithIndex.setRange(0, salt.length, salt);
saltWithIndex[salt.length] = (blockIndex >> 24) & 0xff;
saltWithIndex[salt.length + 1] = (blockIndex >> 16) & 0xff;
saltWithIndex[salt.length + 2] = (blockIndex >> 8) & 0xff;
saltWithIndex[salt.length + 3] = blockIndex & 0xff;
var u = hmacTemplate.convert(saltWithIndex).bytes;
var xorResult = Uint8List.fromList(u);
for (var i = 1; i < iterations; i++) {
u = hmacTemplate.convert(u).bytes;
for (var j = 0; j < xorResult.length; j++) {
xorResult[j] ^= u[j];
}
}
result.add(xorResult);
}
return Uint8List.fromList(result.toBytes().sublist(0, keyLength));
}
}

View File

@@ -0,0 +1,55 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../../config/env.dart';
class TokenService {
/// Create an access token (JWT) for the given [userId].
///
/// The token is signed with HS256, expires in 15 minutes, and contains
/// a `sub` (subject) claim set to the user's ID.
static String createAccessToken(String userId) {
final jwt = JWT(
{
'sub': userId,
'type': 'access',
},
issuer: 'cleanplate_api',
);
return jwt.sign(
SecretKey(Env.jwtSecret),
algorithm: JWTAlgorithm.HS256,
expiresIn: const Duration(minutes: 15),
);
}
/// Verify an access token and return the userId (`sub` claim) if valid.
/// Returns `null` when the token is invalid or expired.
static String? verifyAccessToken(String token) {
try {
final jwt = JWT.verify(token, SecretKey(Env.jwtSecret));
final payload = jwt.payload as Map<String, dynamic>;
if (payload['type'] != 'access') return null;
return payload['sub'] as String?;
} on JWTExpiredException {
return null;
} on JWTException {
return null;
}
}
/// Generate a cryptographically random refresh token (64-byte hex string).
static String generateRefreshToken() {
final random = Random.secure();
final bytes = List<int>.generate(64, (_) => random.nextInt(256));
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}
/// Hash a refresh token with SHA-256 for safe storage in the database.
static String hashRefreshToken(String token) {
final bytes = utf8.encode(token);
return sha256.convert(bytes).toString();
}
}

View File

@@ -0,0 +1,71 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import 'review_service.dart';
class CommunityRoutes {
Router get router {
final router = Router();
router.get('/', _listReviews);
router.post('/', _createReview);
router.delete('/<reviewId>', _deleteReview);
return router;
}
Future<Response> _listReviews(Request request) async {
// The recipeId is passed via the request context (set by the parent mount).
final recipeId = request.context['recipeId'] as String?;
if (recipeId == null) {
return ApiResponse.error('BAD_REQUEST', 'Missing recipeId');
}
final limit =
int.tryParse(request.url.queryParameters['limit'] ?? '20') ?? 20;
final offset =
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
final result = await ReviewService.listReviews(
recipeId: recipeId,
limit: limit,
offset: offset,
);
return ApiResponse.success(
result['reviews'],
meta: result['meta'] as Map<String, dynamic>,
);
}
Future<Response> _createReview(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final recipeId = request.context['recipeId'] as String?;
if (recipeId == null) {
return ApiResponse.error('BAD_REQUEST', 'Missing recipeId');
}
final body =
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final review = await ReviewService.createReview(
recipeId: recipeId,
userId: userId,
rating: body['rating'] as int? ?? 0,
comment: body['comment'] as String?,
);
return ApiResponse.created(review);
}
Future<Response> _deleteReview(Request request, String reviewId) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
await ReviewService.deleteReview(reviewId: reviewId, userId: userId);
return ApiResponse.noContent();
}
}

View File

@@ -0,0 +1,119 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import '../../config/database.dart';
import '../../middleware/error_handler.dart';
final _log = Logger('ReviewService');
const _uuid = Uuid();
/// Review / rating service for community features.
class ReviewService {
/// Create a review for a recipe.
static Future<Map<String, dynamic>> createReview({
required String recipeId,
required String userId,
required int rating,
String? comment,
}) async {
if (rating < 1 || rating > 5) {
throw ApiException.badRequest('Rating must be between 1 and 5');
}
final id = _uuid.v4();
final now = DateTime.now().toUtc();
await Database.execute(
'''INSERT INTO reviews (id, recipe_id, user_id, rating, comment, created_at)
VALUES (@id, @recipeId, @userId, @rating, @comment, @createdAt)''',
parameters: {
'id': id,
'recipeId': recipeId,
'userId': userId,
'rating': rating,
'comment': comment,
'createdAt': now,
},
);
_log.info('Review created: $id for recipe $recipeId by user $userId');
return {
'id': id,
'recipe_id': recipeId,
'user_id': userId,
'rating': rating,
'comment': comment,
'created_at': now.toIso8601String(),
};
}
/// List reviews for a recipe.
static Future<Map<String, dynamic>> listReviews({
required String recipeId,
int limit = 20,
int offset = 0,
}) async {
final countResult = await Database.query(
'SELECT COUNT(*) FROM reviews WHERE recipe_id = @recipeId',
parameters: {'recipeId': recipeId},
);
final totalCount = countResult.first[0] as int;
final rows = await Database.query(
'''SELECT id, user_id, rating, comment, created_at
FROM reviews
WHERE recipe_id = @recipeId
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset''',
parameters: {
'recipeId': recipeId,
'limit': limit,
'offset': offset,
},
);
final reviews = rows
.map((r) => {
'id': r[0],
'user_id': r[1],
'rating': r[2],
'comment': r[3],
'created_at': (r[4] as DateTime).toIso8601String(),
})
.toList();
return {
'reviews': reviews,
'meta': {
'total_count': totalCount,
'limit': limit,
'offset': offset,
},
};
}
/// Delete a review (only the author can delete).
static Future<void> deleteReview({
required String reviewId,
required String userId,
}) async {
final result = await Database.query(
'SELECT user_id FROM reviews WHERE id = @id',
parameters: {'id': reviewId},
);
if (result.isEmpty) {
throw ApiException.notFound('Review not found');
}
if (result.first[0] as String != userId) {
throw ApiException.forbidden('You can only delete your own reviews');
}
await Database.execute(
'DELETE FROM reviews WHERE id = @id',
parameters: {'id': reviewId},
);
_log.info('Review deleted: $reviewId');
}
}

View File

@@ -0,0 +1,25 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
class MediaRoutes {
Router get router {
final router = Router();
router.post('/upload', _upload);
return router;
}
Future<Response> _upload(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
// TODO: Parse multipart form data, extract file, call MediaService.
return ApiResponse.error(
'NOT_IMPLEMENTED',
'Media upload is not yet implemented',
statusCode: 501,
);
}
}

View File

@@ -0,0 +1,25 @@
import 'package:logging/logging.dart';
final _log = Logger('MediaService');
/// Media upload and processing service.
///
/// TODO: Implement MinIO / S3-compatible object storage integration.
class MediaService {
/// Upload an image and return its public URL.
static Future<Map<String, dynamic>> uploadImage({
required String userId,
required List<int> bytes,
required String filename,
required String contentType,
}) async {
_log.info('Upload image: $filename (${bytes.length} bytes) by user $userId');
// Placeholder — return a fake URL.
return {
'url': 'https://storage.cleanplate.app/images/$filename',
'filename': filename,
'size': bytes.length,
'content_type': contentType,
};
}
}

View File

@@ -0,0 +1,349 @@
import 'package:postgres/postgres.dart';
import '../../config/database.dart';
/// All PostgreSQL queries for the recipes module.
///
/// Every query uses parameterised placeholders to prevent SQL injection.
class RecipeRepository {
// ------------------------------------------------------------------
// CREATE
// ------------------------------------------------------------------
/// Insert a recipe header row and return the raw result.
static Future<void> insertRecipe(
TxSession tx, {
required String id,
required String userId,
required String title,
required String? description,
required int? prepTime,
required int? cookTime,
required int? servings,
required String? difficulty,
required String? cuisine,
required List<String> tags,
required String? imageUrl,
required bool isAiGenerated,
required DateTime createdAt,
}) async {
await tx.execute(
Sql.named('''
INSERT INTO recipes
(id, user_id, title, description, prep_time, cook_time, servings,
difficulty, cuisine, tags, image_url, is_ai_generated, created_at, updated_at)
VALUES
(@id, @userId, @title, @description, @prepTime, @cookTime, @servings,
@difficulty, @cuisine, @tags, @imageUrl, @isAiGenerated, @createdAt, @createdAt)
'''),
parameters: {
'id': id,
'userId': userId,
'title': title,
'description': description,
'prepTime': prepTime,
'cookTime': cookTime,
'servings': servings,
'difficulty': difficulty,
'cuisine': cuisine,
'tags': tags,
'imageUrl': imageUrl,
'isAiGenerated': isAiGenerated,
'createdAt': createdAt,
},
);
}
/// Insert a single ingredient row.
static Future<void> insertIngredient(
TxSession tx, {
required String id,
required String recipeId,
required String name,
required String? quantity,
required String? unit,
required int sortOrder,
}) async {
await tx.execute(
Sql.named('''
INSERT INTO ingredients (id, recipe_id, name, quantity, unit, sort_order)
VALUES (@id, @recipeId, @name, @quantity, @unit, @sortOrder)
'''),
parameters: {
'id': id,
'recipeId': recipeId,
'name': name,
'quantity': quantity,
'unit': unit,
'sortOrder': sortOrder,
},
);
}
/// Insert a single step row.
static Future<void> insertStep(
TxSession tx, {
required String id,
required String recipeId,
required int stepNumber,
required String instruction,
required int? durationMinutes,
}) async {
await tx.execute(
Sql.named('''
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
VALUES (@id, @recipeId, @stepNumber, @instruction, @durationMinutes)
'''),
parameters: {
'id': id,
'recipeId': recipeId,
'stepNumber': stepNumber,
'instruction': instruction,
'durationMinutes': durationMinutes,
},
);
}
// ------------------------------------------------------------------
// READ (list)
// ------------------------------------------------------------------
/// Count recipes matching optional filters.
static Future<int> countRecipes({
String? cuisine,
String? difficulty,
String? userId,
}) async {
final where = <String>['deleted_at IS NULL'];
final params = <String, dynamic>{};
if (cuisine != null) {
where.add('cuisine = @cuisine');
params['cuisine'] = cuisine;
}
if (difficulty != null) {
where.add('difficulty = @difficulty');
params['difficulty'] = difficulty;
}
if (userId != null) {
where.add('user_id = @userId');
params['userId'] = userId;
}
final result = await Database.query(
'SELECT COUNT(*) FROM recipes WHERE ${where.join(' AND ')}',
parameters: params,
);
return result.first[0] as int;
}
/// List recipes with pagination and optional filters.
static Future<Result> listRecipes({
required int limit,
required int offset,
String? cuisine,
String? difficulty,
String? userId,
}) async {
final where = <String>['deleted_at IS NULL'];
final params = <String, dynamic>{
'limit': limit,
'offset': offset,
};
if (cuisine != null) {
where.add('cuisine = @cuisine');
params['cuisine'] = cuisine;
}
if (difficulty != null) {
where.add('difficulty = @difficulty');
params['difficulty'] = difficulty;
}
if (userId != null) {
where.add('user_id = @userId');
params['userId'] = userId;
}
return Database.query(
'''SELECT id, user_id, title, description, prep_time, cook_time,
servings, difficulty, cuisine, tags, image_url, is_ai_generated,
created_at
FROM recipes
WHERE ${where.join(' AND ')}
ORDER BY created_at DESC
LIMIT @limit OFFSET @offset''',
parameters: params,
);
}
// ------------------------------------------------------------------
// READ (single)
// ------------------------------------------------------------------
/// Fetch a single recipe by id.
static Future<Result> getRecipeById(String recipeId) async {
return Database.query(
'''SELECT id, user_id, title, description, prep_time, cook_time,
servings, difficulty, cuisine, tags, image_url, is_ai_generated,
created_at, updated_at
FROM recipes
WHERE id = @id AND deleted_at IS NULL''',
parameters: {'id': recipeId},
);
}
/// Fetch ingredients for a recipe.
static Future<Result> getIngredients(String recipeId) async {
return Database.query(
'''SELECT id, name, quantity, unit, sort_order
FROM ingredients
WHERE recipe_id = @recipeId
ORDER BY sort_order''',
parameters: {'recipeId': recipeId},
);
}
/// Fetch steps for a recipe.
static Future<Result> getSteps(String recipeId) async {
return Database.query(
'''SELECT id, step_number, instruction, duration_minutes
FROM steps
WHERE recipe_id = @recipeId
ORDER BY step_number''',
parameters: {'recipeId': recipeId},
);
}
// ------------------------------------------------------------------
// UPDATE
// ------------------------------------------------------------------
/// Update a recipe's mutable fields.
static Future<void> updateRecipe({
required String recipeId,
required String userId,
String? title,
String? description,
int? prepTime,
int? cookTime,
int? servings,
String? difficulty,
String? cuisine,
List<String>? tags,
String? imageUrl,
}) async {
final sets = <String>['updated_at = NOW()'];
final params = <String, dynamic>{
'id': recipeId,
'userId': userId,
};
if (title != null) {
sets.add('title = @title');
params['title'] = title;
}
if (description != null) {
sets.add('description = @description');
params['description'] = description;
}
if (prepTime != null) {
sets.add('prep_time = @prepTime');
params['prepTime'] = prepTime;
}
if (cookTime != null) {
sets.add('cook_time = @cookTime');
params['cookTime'] = cookTime;
}
if (servings != null) {
sets.add('servings = @servings');
params['servings'] = servings;
}
if (difficulty != null) {
sets.add('difficulty = @difficulty');
params['difficulty'] = difficulty;
}
if (cuisine != null) {
sets.add('cuisine = @cuisine');
params['cuisine'] = cuisine;
}
if (tags != null) {
sets.add('tags = @tags');
params['tags'] = tags;
}
if (imageUrl != null) {
sets.add('image_url = @imageUrl');
params['imageUrl'] = imageUrl;
}
await Database.execute(
'UPDATE recipes SET ${sets.join(', ')} WHERE id = @id AND user_id = @userId AND deleted_at IS NULL',
parameters: params,
);
}
// ------------------------------------------------------------------
// DELETE (soft)
// ------------------------------------------------------------------
static Future<Result> softDeleteRecipe(
{required String recipeId, required String userId}) async {
return Database.execute(
'UPDATE recipes SET deleted_at = NOW() WHERE id = @id AND user_id = @userId AND deleted_at IS NULL',
parameters: {'id': recipeId, 'userId': userId},
);
}
// ------------------------------------------------------------------
// SAVE / UNSAVE
// ------------------------------------------------------------------
static Future<void> saveRecipe({
required String id,
required String userId,
required String recipeId,
}) async {
await Database.execute(
'''INSERT INTO saved_recipes (id, user_id, recipe_id, created_at)
VALUES (@id, @userId, @recipeId, NOW())
ON CONFLICT (user_id, recipe_id) DO NOTHING''',
parameters: {'id': id, 'userId': userId, 'recipeId': recipeId},
);
}
static Future<void> unsaveRecipe({
required String userId,
required String recipeId,
}) async {
await Database.execute(
'DELETE FROM saved_recipes WHERE user_id = @userId AND recipe_id = @recipeId',
parameters: {'userId': userId, 'recipeId': recipeId},
);
}
/// Count saved recipes for a user.
static Future<int> countSavedRecipes(String userId) async {
final result = await Database.query(
'SELECT COUNT(*) FROM saved_recipes WHERE user_id = @userId',
parameters: {'userId': userId},
);
return result.first[0] as int;
}
/// List saved recipe ids for a user (paginated).
static Future<Result> listSavedRecipes({
required String userId,
required int limit,
required int offset,
}) async {
return Database.query(
'''SELECT r.id, r.user_id, r.title, r.description, r.prep_time,
r.cook_time, r.servings, r.difficulty, r.cuisine, r.tags,
r.image_url, r.is_ai_generated, r.created_at
FROM saved_recipes sr
JOIN recipes r ON r.id = sr.recipe_id AND r.deleted_at IS NULL
WHERE sr.user_id = @userId
ORDER BY sr.created_at DESC
LIMIT @limit OFFSET @offset''',
parameters: {'userId': userId, 'limit': limit, 'offset': offset},
);
}
}

View File

@@ -0,0 +1,145 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import '../../shared/pagination.dart';
import 'recipe_service.dart';
class RecipeRoutes {
Router get router {
final router = Router();
// Public
router.get('/', _listRecipes);
router.get('/saved', _listSavedRecipes);
router.get('/<id>', _getRecipe);
// Authenticated
router.post('/', _createRecipe);
router.put('/<id>', _updateRecipe);
router.delete('/<id>', _deleteRecipe);
// Save / unsave
router.post('/<id>/save', _saveRecipe);
router.delete('/<id>/save', _unsaveRecipe);
return router;
}
// ------------------------------------------------------------------
// Handlers
// ------------------------------------------------------------------
Future<Response> _listRecipes(Request request) async {
final pagination = PaginationParams.fromRequest(request);
final params = request.url.queryParameters;
final result = await RecipeService.listRecipes(
pagination: pagination,
cuisine: params['cuisine'],
difficulty: params['difficulty'],
userId: params['user_id'],
);
return ApiResponse.success(
result['recipes'],
meta: result['meta'] as Map<String, dynamic>,
);
}
Future<Response> _getRecipe(Request request, String id) async {
final recipe = await RecipeService.getRecipeById(id);
return ApiResponse.success(recipe);
}
Future<Response> _createRecipe(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final body =
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final recipe = await RecipeService.createRecipe(
userId: userId,
title: body['title'] as String? ?? '',
description: body['description'] as String?,
prepTime: body['prep_time'] as int?,
cookTime: body['cook_time'] as int?,
servings: body['servings'] as int?,
difficulty: body['difficulty'] as String?,
cuisine: body['cuisine'] as String?,
tags: (body['tags'] as List<dynamic>?)?.cast<String>(),
imageUrl: body['image_url'] as String?,
isAiGenerated: body['is_ai_generated'] as bool? ?? false,
ingredients:
(body['ingredients'] as List<dynamic>?)?.cast<Map<String, dynamic>>(),
steps: (body['steps'] as List<dynamic>?)?.cast<Map<String, dynamic>>(),
);
return ApiResponse.created(recipe);
}
Future<Response> _updateRecipe(Request request, String id) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final body =
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final recipe = await RecipeService.updateRecipe(
recipeId: id,
userId: userId,
title: body['title'] as String?,
description: body['description'] as String?,
prepTime: body['prep_time'] as int?,
cookTime: body['cook_time'] as int?,
servings: body['servings'] as int?,
difficulty: body['difficulty'] as String?,
cuisine: body['cuisine'] as String?,
tags: (body['tags'] as List<dynamic>?)?.cast<String>(),
imageUrl: body['image_url'] as String?,
);
return ApiResponse.success(recipe);
}
Future<Response> _deleteRecipe(Request request, String id) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
await RecipeService.deleteRecipe(recipeId: id, userId: userId);
return ApiResponse.noContent();
}
Future<Response> _saveRecipe(Request request, String id) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
await RecipeService.saveRecipe(userId: userId, recipeId: id);
return ApiResponse.success({'saved': true}, statusCode: 201);
}
Future<Response> _unsaveRecipe(Request request, String id) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
await RecipeService.unsaveRecipe(userId: userId, recipeId: id);
return ApiResponse.noContent();
}
Future<Response> _listSavedRecipes(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final pagination = PaginationParams.fromRequest(request);
final result = await RecipeService.listSavedRecipes(
userId: userId,
pagination: pagination,
);
return ApiResponse.success(
result['recipes'],
meta: result['meta'] as Map<String, dynamic>,
);
}
}

View File

@@ -0,0 +1,317 @@
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';
import '../../config/database.dart';
import '../../middleware/error_handler.dart';
import '../../shared/pagination.dart';
import 'recipe_repository.dart';
final _log = Logger('RecipeService');
const _uuid = Uuid();
class RecipeService {
// ------------------------------------------------------------------
// CREATE
// ------------------------------------------------------------------
/// Create a recipe together with its ingredients and steps inside a single
/// database transaction.
static Future<Map<String, dynamic>> createRecipe({
required String userId,
required String title,
String? description,
int? prepTime,
int? cookTime,
int? servings,
String? difficulty,
String? cuisine,
List<String>? tags,
String? imageUrl,
bool isAiGenerated = false,
List<Map<String, dynamic>>? ingredients,
List<Map<String, dynamic>>? steps,
}) async {
if (title.isEmpty) {
throw ApiException.badRequest('Title is required');
}
final recipeId = _uuid.v4();
final now = DateTime.now().toUtc();
await Database.transaction((tx) async {
await RecipeRepository.insertRecipe(
tx,
id: recipeId,
userId: userId,
title: title,
description: description,
prepTime: prepTime,
cookTime: cookTime,
servings: servings,
difficulty: difficulty,
cuisine: cuisine,
tags: tags ?? [],
imageUrl: imageUrl,
isAiGenerated: isAiGenerated,
createdAt: now,
);
// Ingredients.
if (ingredients != null) {
for (var i = 0; i < ingredients.length; i++) {
final ing = ingredients[i];
await RecipeRepository.insertIngredient(
tx,
id: _uuid.v4(),
recipeId: recipeId,
name: ing['name'] as String? ?? '',
quantity: ing['quantity'] as String?,
unit: ing['unit'] as String?,
sortOrder: i,
);
}
}
// Steps.
if (steps != null) {
for (var i = 0; i < steps.length; i++) {
final step = steps[i];
await RecipeRepository.insertStep(
tx,
id: _uuid.v4(),
recipeId: recipeId,
stepNumber: i + 1,
instruction: step['instruction'] as String? ?? '',
durationMinutes: step['duration_minutes'] as int?,
);
}
}
});
_log.info('Recipe created: $recipeId by user $userId');
return getRecipeById(recipeId);
}
// ------------------------------------------------------------------
// LIST
// ------------------------------------------------------------------
/// List recipes with pagination and optional filters.
static Future<Map<String, dynamic>> listRecipes({
required PaginationParams pagination,
String? cuisine,
String? difficulty,
String? userId,
}) async {
final totalCount = await RecipeRepository.countRecipes(
cuisine: cuisine,
difficulty: difficulty,
userId: userId,
);
final rows = await RecipeRepository.listRecipes(
limit: pagination.limit,
offset: pagination.offset,
cuisine: cuisine,
difficulty: difficulty,
userId: userId,
);
final recipes = rows.map(_rowToRecipeSummary).toList();
return {
'recipes': recipes,
'meta': pagination.toMeta(totalCount),
};
}
// ------------------------------------------------------------------
// GET BY ID
// ------------------------------------------------------------------
/// Fetch a single recipe with its ingredients and steps.
static Future<Map<String, dynamic>> getRecipeById(String recipeId) async {
final recipeRows = await RecipeRepository.getRecipeById(recipeId);
if (recipeRows.isEmpty) {
throw ApiException.notFound('Recipe not found');
}
final row = recipeRows.first;
final ingredientRows = await RecipeRepository.getIngredients(recipeId);
final stepRows = await RecipeRepository.getSteps(recipeId);
return {
'id': row[0],
'user_id': row[1],
'title': row[2],
'description': row[3],
'prep_time': row[4],
'cook_time': row[5],
'servings': row[6],
'difficulty': row[7],
'cuisine': row[8],
'tags': row[9],
'image_url': row[10],
'is_ai_generated': row[11],
'created_at': (row[12] as DateTime).toIso8601String(),
'updated_at': (row[13] as DateTime).toIso8601String(),
'ingredients': ingredientRows
.map((r) => {
'id': r[0],
'name': r[1],
'quantity': r[2],
'unit': r[3],
'sort_order': r[4],
})
.toList(),
'steps': stepRows
.map((r) => {
'id': r[0],
'step_number': r[1],
'instruction': r[2],
'duration_minutes': r[3],
})
.toList(),
};
}
// ------------------------------------------------------------------
// UPDATE
// ------------------------------------------------------------------
/// Update a recipe's fields. Only the owner can update.
static Future<Map<String, dynamic>> updateRecipe({
required String recipeId,
required String userId,
String? title,
String? description,
int? prepTime,
int? cookTime,
int? servings,
String? difficulty,
String? cuisine,
List<String>? tags,
String? imageUrl,
}) async {
// Verify ownership.
final existing = await RecipeRepository.getRecipeById(recipeId);
if (existing.isEmpty) {
throw ApiException.notFound('Recipe not found');
}
if (existing.first[1] as String != userId) {
throw ApiException.forbidden('You can only update your own recipes');
}
await RecipeRepository.updateRecipe(
recipeId: recipeId,
userId: userId,
title: title,
description: description,
prepTime: prepTime,
cookTime: cookTime,
servings: servings,
difficulty: difficulty,
cuisine: cuisine,
tags: tags,
imageUrl: imageUrl,
);
_log.info('Recipe updated: $recipeId');
return getRecipeById(recipeId);
}
// ------------------------------------------------------------------
// DELETE (soft)
// ------------------------------------------------------------------
/// Soft-delete a recipe. Only the owner can delete.
static Future<void> deleteRecipe({
required String recipeId,
required String userId,
}) async {
final existing = await RecipeRepository.getRecipeById(recipeId);
if (existing.isEmpty) {
throw ApiException.notFound('Recipe not found');
}
if (existing.first[1] as String != userId) {
throw ApiException.forbidden('You can only delete your own recipes');
}
await RecipeRepository.softDeleteRecipe(
recipeId: recipeId, userId: userId);
_log.info('Recipe soft-deleted: $recipeId');
}
// ------------------------------------------------------------------
// SAVE / UNSAVE
// ------------------------------------------------------------------
/// Save (bookmark) a recipe for the authenticated user.
static Future<void> saveRecipe({
required String userId,
required String recipeId,
}) async {
final existing = await RecipeRepository.getRecipeById(recipeId);
if (existing.isEmpty) {
throw ApiException.notFound('Recipe not found');
}
await RecipeRepository.saveRecipe(
id: _uuid.v4(),
userId: userId,
recipeId: recipeId,
);
_log.info('Recipe saved: $recipeId by user $userId');
}
/// Remove a saved recipe bookmark.
static Future<void> unsaveRecipe({
required String userId,
required String recipeId,
}) async {
await RecipeRepository.unsaveRecipe(userId: userId, recipeId: recipeId);
_log.info('Recipe unsaved: $recipeId by user $userId');
}
/// List saved recipes for the authenticated user.
static Future<Map<String, dynamic>> listSavedRecipes({
required String userId,
required PaginationParams pagination,
}) async {
final totalCount = await RecipeRepository.countSavedRecipes(userId);
final rows = await RecipeRepository.listSavedRecipes(
userId: userId,
limit: pagination.limit,
offset: pagination.offset,
);
final recipes = rows.map(_rowToRecipeSummary).toList();
return {
'recipes': recipes,
'meta': pagination.toMeta(totalCount),
};
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
static Map<String, dynamic> _rowToRecipeSummary(dynamic row) {
return {
'id': row[0],
'user_id': row[1],
'title': row[2],
'description': row[3],
'prep_time': row[4],
'cook_time': row[5],
'servings': row[6],
'difficulty': row[7],
'cuisine': row[8],
'tags': row[9],
'image_url': row[10],
'is_ai_generated': row[11],
'created_at': (row[12] as DateTime).toIso8601String(),
};
}
}

View File

@@ -0,0 +1,37 @@
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import 'search_service.dart';
class SearchRoutes {
Router get router {
final router = Router();
router.get('/', _search);
return router;
}
Future<Response> _search(Request request) async {
final query = request.url.queryParameters['q'] ?? '';
if (query.isEmpty) {
return ApiResponse.error('BAD_REQUEST', 'Query parameter "q" is required');
}
final limit =
int.tryParse(request.url.queryParameters['limit'] ?? '20') ?? 20;
final offset =
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
final result = await SearchService.searchRecipes(
query: query,
limit: limit,
offset: offset,
);
return ApiResponse.success(
result['recipes'],
meta: result['meta'] as Map<String, dynamic>,
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:logging/logging.dart';
final _log = Logger('SearchService');
/// Meilisearch integration for full-text recipe search.
///
/// TODO: Implement Meilisearch HTTP client calls.
class SearchService {
static Future<Map<String, dynamic>> searchRecipes({
required String query,
int limit = 20,
int offset = 0,
Map<String, dynamic>? filters,
}) async {
_log.info('Search query: "$query" (limit=$limit, offset=$offset)');
// Placeholder — return empty results until Meilisearch is wired up.
return {
'recipes': <Map<String, dynamic>>[],
'meta': {
'query': query,
'total_count': 0,
'limit': limit,
'offset': offset,
},
};
}
/// Index a recipe document in Meilisearch after creation / update.
static Future<void> indexRecipe(Map<String, dynamic> recipe) async {
_log.info('Indexing recipe: ${recipe['id']}');
// TODO: POST to Meilisearch /indexes/recipes/documents
}
/// Remove a recipe document from the index after deletion.
static Future<void> removeRecipe(String recipeId) async {
_log.info('Removing recipe from index: $recipeId');
// TODO: DELETE from Meilisearch index
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../shared/api_response.dart';
import 'user_service.dart';
class UserRoutes {
Router get router {
final router = Router();
router.get('/me', _getMe);
router.put('/me', _updateMe);
router.get('/<id>', _getProfile);
return router;
}
Future<Response> _getMe(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final profile = await UserService.getProfile(userId);
return ApiResponse.success(profile);
}
Future<Response> _updateMe(Request request) async {
final userId = request.context['userId'] as String?;
if (userId == null) return ApiResponse.unauthorized();
final body =
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
final profile = await UserService.updateProfile(
userId: userId,
username: body['username'] as String?,
bio: body['bio'] as String?,
avatarUrl: body['avatar_url'] as String?,
);
return ApiResponse.success(profile);
}
Future<Response> _getProfile(Request request, String id) async {
final profile = await UserService.getProfile(id);
return ApiResponse.success(profile);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:logging/logging.dart';
import '../../config/database.dart';
import '../../middleware/error_handler.dart';
final _log = Logger('UserService');
/// User profile service.
class UserService {
/// Fetch the public profile of a user by ID.
static Future<Map<String, dynamic>> getProfile(String userId) async {
final result = await Database.query(
'''SELECT id, username, bio, avatar_url, created_at
FROM users
WHERE id = @id AND deleted_at IS NULL''',
parameters: {'id': userId},
);
if (result.isEmpty) {
throw ApiException.notFound('User not found');
}
final row = result.first;
return {
'id': row[0],
'username': row[1],
'bio': row[2],
'avatar_url': row[3],
'created_at': (row[4] as DateTime).toIso8601String(),
};
}
/// Update the authenticated user's profile fields.
static Future<Map<String, dynamic>> updateProfile({
required String userId,
String? username,
String? bio,
String? avatarUrl,
}) async {
final sets = <String>['updated_at = NOW()'];
final params = <String, dynamic>{'id': userId};
if (username != null) {
sets.add('username = @username');
params['username'] = username;
}
if (bio != null) {
sets.add('bio = @bio');
params['bio'] = bio;
}
if (avatarUrl != null) {
sets.add('avatar_url = @avatarUrl');
params['avatarUrl'] = avatarUrl;
}
await Database.execute(
'UPDATE users SET ${sets.join(', ')} WHERE id = @id AND deleted_at IS NULL',
parameters: params,
);
_log.info('Profile updated: $userId');
return getProfile(userId);
}
}

View File

@@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
class ApiResponse {
static Response success(dynamic data,
{Map<String, dynamic>? meta, int statusCode = 200}) {
return Response(statusCode,
headers: {'content-type': 'application/json'},
body: jsonEncode({
'status': 'success',
'data': data,
if (meta != null) 'meta': meta,
}));
}
static Response error(String code, String message,
{int statusCode = 400, dynamic details}) {
return Response(statusCode,
headers: {'content-type': 'application/json'},
body: jsonEncode({
'status': 'error',
'error': {
'code': code,
'message': message,
if (details != null) 'details': details,
},
}));
}
static Response created(dynamic data) => success(data, statusCode: 201);
static Response noContent() => Response(204);
static Response notFound(String message) =>
error('NOT_FOUND', message, statusCode: 404);
static Response unauthorized([String message = 'Unauthorized']) =>
error('UNAUTHORIZED', message, statusCode: 401);
static Response forbidden([String message = 'Forbidden']) =>
error('FORBIDDEN', message, statusCode: 403);
static Response internalError([String message = 'Internal server error']) =>
error('INTERNAL_ERROR', message, statusCode: 500);
}

View File

@@ -0,0 +1,29 @@
import 'dart:math';
import 'package:shelf/shelf.dart';
class PaginationParams {
final int page;
final int perPage;
PaginationParams({required this.page, required this.perPage});
int get offset => (page - 1) * perPage;
int get limit => perPage;
factory PaginationParams.fromRequest(Request request) {
final queryParams = request.url.queryParameters;
final page = max(1, int.tryParse(queryParams['page'] ?? '1') ?? 1);
final perPage = min(
100, max(1, int.tryParse(queryParams['per_page'] ?? '20') ?? 20));
return PaginationParams(page: page, perPage: perPage);
}
Map<String, dynamic> toMeta(int totalCount) {
return {
'page': page,
'per_page': perPage,
'total_count': totalCount,
'total_pages': (totalCount / perPage).ceil(),
};
}
}

View File

@@ -0,0 +1,380 @@
-- CleanPlate Initial Schema
-- Migration 001: Core tables for MVP
BEGIN;
-- ============================================================
-- EXTENSIONS
-- ============================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================================
-- USERS & AUTH
-- ============================================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(100) NOT NULL,
avatar_url VARCHAR(500),
subscription_tier VARCHAR(20) DEFAULT 'free',
subscription_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id) WHERE revoked_at IS NULL;
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
CREATE TABLE dietary_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
diet_type VARCHAR(50),
allergies TEXT[] DEFAULT '{}',
intolerances TEXT[] DEFAULT '{}',
calorie_target INTEGER,
excluded_ingredients TEXT[] DEFAULT '{}',
preferences_json JSONB DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- HOUSEHOLDS
-- ============================================================
CREATE TABLE households (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE household_members (
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (household_id, user_id)
);
-- ============================================================
-- CANONICAL INGREDIENTS (reference dictionary)
-- ============================================================
CREATE TABLE canonical_ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) UNIQUE NOT NULL,
category VARCHAR(50),
aliases TEXT[] DEFAULT '{}',
nutrition_per_100g JSONB,
common_units TEXT[] DEFAULT '{}'
);
CREATE INDEX idx_canonical_name ON canonical_ingredients(name);
-- ============================================================
-- RECIPES
-- ============================================================
CREATE TABLE recipes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_id UUID REFERENCES users(id),
title VARCHAR(300) NOT NULL,
slug VARCHAR(350) UNIQUE NOT NULL,
description TEXT,
cuisine VARCHAR(50),
difficulty VARCHAR(20),
prep_time_min INTEGER,
cook_time_min INTEGER,
total_time_min INTEGER GENERATED ALWAYS AS (
COALESCE(prep_time_min, 0) + COALESCE(cook_time_min, 0)
) STORED,
servings INTEGER DEFAULT 4,
source_type VARCHAR(20) DEFAULT 'user',
status VARCHAR(20) DEFAULT 'published',
cover_image_url VARCHAR(500),
video_url VARCHAR(500),
tags TEXT[] DEFAULT '{}',
diet_labels TEXT[] DEFAULT '{}',
rating_avg NUMERIC(3,2) DEFAULT 0,
rating_count INTEGER DEFAULT 0,
save_count INTEGER DEFAULT 0,
version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_recipes_cuisine ON recipes(cuisine);
CREATE INDEX idx_recipes_difficulty ON recipes(difficulty);
CREATE INDEX idx_recipes_total_time ON recipes(total_time_min);
CREATE INDEX idx_recipes_rating ON recipes(rating_avg DESC);
CREATE INDEX idx_recipes_tags ON recipes USING GIN(tags);
CREATE INDEX idx_recipes_diet_labels ON recipes USING GIN(diet_labels);
CREATE INDEX idx_recipes_status ON recipes(status) WHERE status = 'published';
CREATE INDEX idx_recipes_author ON recipes(author_id);
-- Full-text search vector
ALTER TABLE recipes ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C')
) STORED;
CREATE INDEX idx_recipes_search ON recipes USING GIN(search_vector);
-- ============================================================
-- INGREDIENTS
-- ============================================================
CREATE TABLE ingredients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
quantity NUMERIC(10,3),
unit VARCHAR(50),
group_name VARCHAR(100),
sort_order INTEGER NOT NULL,
optional BOOLEAN DEFAULT FALSE,
canonical_ingredient_id UUID REFERENCES canonical_ingredients(id)
);
CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id);
CREATE INDEX idx_ingredients_canonical ON ingredients(canonical_ingredient_id);
-- ============================================================
-- RECIPE STEPS
-- ============================================================
CREATE TABLE recipe_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
instruction TEXT NOT NULL,
duration_min INTEGER,
image_url VARCHAR(500),
tip TEXT,
UNIQUE(recipe_id, step_number)
);
CREATE INDEX idx_steps_recipe ON recipe_steps(recipe_id);
-- ============================================================
-- RECIPE NUTRITION
-- ============================================================
CREATE TABLE recipe_nutrition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID UNIQUE NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
per_serving BOOLEAN DEFAULT TRUE,
calories NUMERIC(8,2),
protein_g NUMERIC(8,2),
fat_g NUMERIC(8,2),
saturated_fat_g NUMERIC(8,2),
carbs_g NUMERIC(8,2),
fiber_g NUMERIC(8,2),
sugar_g NUMERIC(8,2),
sodium_mg NUMERIC(8,2),
calculated_at TIMESTAMPTZ DEFAULT NOW(),
source VARCHAR(20) DEFAULT 'auto'
);
-- ============================================================
-- COMMUNITY (Reviews)
-- ============================================================
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
title VARCHAR(200),
body TEXT,
helpful_count INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'published',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(recipe_id, user_id)
);
CREATE INDEX idx_reviews_recipe ON reviews(recipe_id, created_at DESC);
CREATE INDEX idx_reviews_user ON reviews(user_id);
CREATE TABLE review_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
reporter_id UUID NOT NULL REFERENCES users(id),
reason VARCHAR(50) NOT NULL,
details TEXT,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- SAVED RECIPES & COLLECTIONS
-- ============================================================
CREATE TABLE saved_recipes (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
saved_at TIMESTAMPTZ DEFAULT NOW(),
synced_at TIMESTAMPTZ,
PRIMARY KEY (user_id, recipe_id)
);
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE collection_recipes (
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
sort_order INTEGER,
PRIMARY KEY (collection_id, recipe_id)
);
-- ============================================================
-- MEAL PLANNING
-- ============================================================
CREATE TABLE meal_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
household_id UUID REFERENCES households(id),
name VARCHAR(100),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE meal_plan_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
recipe_id UUID NOT NULL REFERENCES recipes(id),
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
meal_type VARCHAR(20) NOT NULL,
servings INTEGER DEFAULT 1,
notes TEXT,
sort_order INTEGER
);
CREATE TABLE grocery_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
generated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE grocery_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE,
ingredient_name VARCHAR(200) NOT NULL,
quantity NUMERIC(10,3),
unit VARCHAR(50),
category VARCHAR(50),
checked BOOLEAN DEFAULT FALSE,
added_manually BOOLEAN DEFAULT FALSE
);
-- ============================================================
-- MEDIA
-- ============================================================
CREATE TABLE media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uploader_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(20) NOT NULL,
original_url VARCHAR(500) NOT NULL,
processed_url VARCHAR(500),
thumbnail_url VARCHAR(500),
status VARCHAR(20) DEFAULT 'processing',
mime_type VARCHAR(100),
size_bytes BIGINT,
duration_sec INTEGER,
width INTEGER,
height INTEGER,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- AI GENERATION LOG
-- ============================================================
CREATE TABLE ai_generations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
prompt_hash VARCHAR(64) NOT NULL,
input_json JSONB NOT NULL,
output_json JSONB,
model_used VARCHAR(100),
tokens_in INTEGER,
tokens_out INTEGER,
latency_ms INTEGER,
cached BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_ai_gen_prompt_hash ON ai_generations(prompt_hash);
CREATE INDEX idx_ai_gen_user ON ai_generations(user_id, created_at DESC);
-- ============================================================
-- SYNC TRACKING
-- ============================================================
CREATE TABLE sync_log (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(20) NOT NULL,
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by UUID REFERENCES users(id)
);
CREATE INDEX idx_sync_log_type_time ON sync_log(entity_type, changed_at);
-- ============================================================
-- UPDATED_AT TRIGGER FUNCTION
-- ============================================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply trigger to tables with updated_at
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_recipes_updated_at BEFORE UPDATE ON recipes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_meal_plans_updated_at BEFORE UPDATE ON meal_plans
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
COMMIT;

701
pubspec.lock Normal file
View File

@@ -0,0 +1,701 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
buffer:
dependency: transitive
description:
name: buffer
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
url: "https://pub.dev"
source: hosted
version: "8.12.4"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dart_jsonwebtoken:
dependency: "direct main"
description:
name: dart_jsonwebtoken
sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef"
url: "https://pub.dev"
source: hosted
version: "2.17.0"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dotenv:
dependency: "direct main"
description:
name: dotenv
sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_methods:
dependency: transitive
description:
name: http_methods
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
version: "6.9.5"
lints:
dependency: "direct dev"
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: "direct main"
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
meta:
dependency: transitive
description:
name: meta
sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f"
url: "https://pub.dev"
source: hosted
version: "1.18.1"
mime:
dependency: "direct main"
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
postgres:
dependency: "direct main"
description:
name: postgres
sha256: "013c6dc668eaab9771c4d3f5fc3e87ed4b3cd4ab3587ac6943cc1f38509ff723"
url: "https://pub.dev"
source: hosted
version: "3.5.7"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
redis:
dependency: "direct main"
description:
name: redis
sha256: "32e28eb1ba2e0fe2af50bbd06e675e4dfdce4f0ba95c5bc885c72383a1b0b47e"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
sasl_scram:
dependency: transitive
description:
name: sasl_scram
sha256: "5c27fd6058d53075c032539ba3cc7fa95006bb1d51a0db63a81b05756c265a83"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
saslprep:
dependency: transitive
description:
name: saslprep
sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
shelf:
dependency: "direct main"
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_cors_headers:
dependency: "direct main"
description:
name: shelf_cors_headers
sha256: a127c80f99bbef3474293db67a7608e3a0f1f0fcdb171dad77fa9bd2cd123ae4
url: "https://pub.dev"
source: hosted
version: "0.1.5"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_router:
dependency: "direct main"
description:
name: shelf_router
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
url: "https://pub.dev"
source: hosted
version: "1.1.4"
shelf_static:
dependency: "direct main"
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
version: "1.3.7"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
unorm_dart:
dependency: transitive
description:
name: unorm_dart
sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.7.0 <4.0.0"

30
pubspec.yaml Normal file
View File

@@ -0,0 +1,30 @@
name: cleanplate_api
description: CleanPlate Recipe App Backend API
version: 0.1.0
publish_to: 'none'
environment:
sdk: ^3.7.0
dependencies:
shelf: ^1.4.0
shelf_router: ^1.1.0
shelf_cors_headers: ^0.1.0
shelf_static: ^1.1.0
postgres: ^3.4.0
redis: ^3.0.0
dart_jsonwebtoken: ^2.14.0
crypto: ^3.0.0
uuid: ^4.5.0
dotenv: ^4.2.0
args: ^2.0.0
http: ^1.0.0
mime: ^2.0.0
logging: ^1.2.0
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0
lints: ^5.0.0
test: ^1.24.0

39
test/server_test.dart Normal file
View File

@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:http/http.dart';
import 'package:test/test.dart';
void main() {
final port = '8080';
final host = 'http://0.0.0.0:$port';
late Process p;
setUp(() async {
p = await Process.start(
'dart',
['run', 'bin/server.dart'],
environment: {'PORT': port},
);
// Wait for server to start and print to stdout.
await p.stdout.first;
});
tearDown(() => p.kill());
test('Root', () async {
final response = await get(Uri.parse('$host/'));
expect(response.statusCode, 200);
expect(response.body, 'Hello, World!\n');
});
test('Echo', () async {
final response = await get(Uri.parse('$host/echo/hello'));
expect(response.statusCode, 200);
expect(response.body, 'hello\n');
});
test('404', () async {
final response = await get(Uri.parse('$host/foobar'));
expect(response.statusCode, 404);
});
}