From 6bd1ab7e9fa32a577f5e31ef5bbaa5a6737de705 Mon Sep 17 00:00:00 2001 From: Oracle Public Cloud User Date: Wed, 4 Mar 2026 14:52:13 +0000 Subject: [PATCH] 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. --- .dockerignore | 9 + .env.example | 26 + .gitignore | 3 + CHANGELOG.md | 3 + Dockerfile | 21 + README.md | 49 ++ analysis_options.yaml | 30 + bin/migrate.dart | 145 ++++ bin/server.dart | 147 ++++ docker-compose.yml | 114 +++ lib/src/config/database.dart | 86 +++ lib/src/config/env.dart | 27 + lib/src/config/redis_config.dart | 43 ++ lib/src/middleware/auth_middleware.dart | 81 ++ lib/src/middleware/cors_middleware.dart | 15 + lib/src/middleware/error_handler.dart | 90 +++ lib/src/middleware/logging_middleware.dart | 35 + lib/src/middleware/rate_limit_middleware.dart | 64 ++ lib/src/modules/ai/ai_routes.dart | 48 ++ lib/src/modules/ai/ai_service.dart | 105 +++ lib/src/modules/ai/llm_client.dart | 86 +++ lib/src/modules/ai/prompt_templates.dart | 99 +++ lib/src/modules/auth/auth_routes.dart | 90 +++ lib/src/modules/auth/auth_service.dart | 203 +++++ lib/src/modules/auth/password_hasher.dart | 86 +++ lib/src/modules/auth/token_service.dart | 55 ++ .../modules/community/community_routes.dart | 71 ++ lib/src/modules/community/review_service.dart | 119 +++ lib/src/modules/media/media_routes.dart | 25 + lib/src/modules/media/media_service.dart | 25 + .../modules/recipes/recipe_repository.dart | 349 +++++++++ lib/src/modules/recipes/recipe_routes.dart | 145 ++++ lib/src/modules/recipes/recipe_service.dart | 317 ++++++++ lib/src/modules/search/search_routes.dart | 37 + lib/src/modules/search/search_service.dart | 39 + lib/src/modules/users/user_routes.dart | 47 ++ lib/src/modules/users/user_service.dart | 63 ++ lib/src/shared/api_response.dart | 40 + lib/src/shared/pagination.dart | 29 + migrations/001_initial_schema.sql | 380 ++++++++++ pubspec.lock | 701 ++++++++++++++++++ pubspec.yaml | 30 + test/server_test.dart | 39 + 43 files changed, 4216 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 bin/migrate.dart create mode 100644 bin/server.dart create mode 100644 docker-compose.yml create mode 100644 lib/src/config/database.dart create mode 100644 lib/src/config/env.dart create mode 100644 lib/src/config/redis_config.dart create mode 100644 lib/src/middleware/auth_middleware.dart create mode 100644 lib/src/middleware/cors_middleware.dart create mode 100644 lib/src/middleware/error_handler.dart create mode 100644 lib/src/middleware/logging_middleware.dart create mode 100644 lib/src/middleware/rate_limit_middleware.dart create mode 100644 lib/src/modules/ai/ai_routes.dart create mode 100644 lib/src/modules/ai/ai_service.dart create mode 100644 lib/src/modules/ai/llm_client.dart create mode 100644 lib/src/modules/ai/prompt_templates.dart create mode 100644 lib/src/modules/auth/auth_routes.dart create mode 100644 lib/src/modules/auth/auth_service.dart create mode 100644 lib/src/modules/auth/password_hasher.dart create mode 100644 lib/src/modules/auth/token_service.dart create mode 100644 lib/src/modules/community/community_routes.dart create mode 100644 lib/src/modules/community/review_service.dart create mode 100644 lib/src/modules/media/media_routes.dart create mode 100644 lib/src/modules/media/media_service.dart create mode 100644 lib/src/modules/recipes/recipe_repository.dart create mode 100644 lib/src/modules/recipes/recipe_routes.dart create mode 100644 lib/src/modules/recipes/recipe_service.dart create mode 100644 lib/src/modules/search/search_routes.dart create mode 100644 lib/src/modules/search/search_service.dart create mode 100644 lib/src/modules/users/user_routes.dart create mode 100644 lib/src/modules/users/user_service.dart create mode 100644 lib/src/shared/api_response.dart create mode 100644 lib/src/shared/pagination.dart create mode 100644 migrations/001_initial_schema.sql create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 test/server_test.dart diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..21504f8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.dockerignore +Dockerfile +build/ +.dart_tool/ +.git/ +.github/ +.gitignore +.idea/ +.packages diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99913ab --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a85790 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c333dee --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e695d9d --- /dev/null +++ b/README.md @@ -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/` + +# 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 +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/bin/migrate.dart b/bin/migrate.dart new file mode 100644 index 0000000..b3b5da7 --- /dev/null +++ b/bin/migrate.dart @@ -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 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() + ) + ''', +]; diff --git a/bin/server.dart b/bin/server.dart new file mode 100644 index 0000000..5ee47a2 --- /dev/null +++ b/bin/server.dart @@ -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 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//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 _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); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a11817 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/lib/src/config/database.dart b/lib/src/config/database.dart new file mode 100644 index 0000000..ee6102b --- /dev/null +++ b/lib/src/config/database.dart @@ -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 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 execute( + String sql, { + Map? parameters, + }) async { + _ensureInitialized(); + return _pool!.execute( + Sql.named(sql), + parameters: parameters ?? {}, + ); + } + + /// Execute a query and return the result rows. + static Future query( + String sql, { + Map? parameters, + }) async { + _ensureInitialized(); + return _pool!.execute( + Sql.named(sql), + parameters: parameters ?? {}, + ); + } + + /// Run multiple statements inside a single transaction. + static Future transaction( + Future Function(TxSession tx) action, + ) async { + _ensureInitialized(); + return _pool!.runTx(action); + } + + /// Close all connections in the pool. + static Future 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.', + ); + } + } +} diff --git a/lib/src/config/env.dart b/lib/src/config/env.dart new file mode 100644 index 0000000..f966626 --- /dev/null +++ b/lib/src/config/env.dart @@ -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'; +} diff --git a/lib/src/config/redis_config.dart b/lib/src/config/redis_config.dart new file mode 100644 index 0000000..6124119 --- /dev/null +++ b/lib/src/config/redis_config.dart @@ -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 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 close() async { + if (_connection != null) { + await _connection!.close(); + _connection = null; + _command = null; + _log.info('Redis connection closed'); + } + } +} diff --git a/lib/src/middleware/auth_middleware.dart b/lib/src/middleware/auth_middleware.dart new file mode 100644 index 0000000..8b407a6 --- /dev/null +++ b/lib/src/middleware/auth_middleware.dart @@ -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/`` 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'); + } + }; + }; +} diff --git a/lib/src/middleware/cors_middleware.dart b/lib/src/middleware/cors_middleware.dart new file mode 100644 index 0000000..6bdf255 --- /dev/null +++ b/lib/src/middleware/cors_middleware.dart @@ -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', + }, + ); +} diff --git a/lib/src/middleware/error_handler.dart b/lib/src/middleware/error_handler.dart new file mode 100644 index 0000000..a22d52d --- /dev/null +++ b/lib/src/middleware/error_handler.dart @@ -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)'; +} diff --git a/lib/src/middleware/logging_middleware.dart b/lib/src/middleware/logging_middleware.dart new file mode 100644 index 0000000..86a3eeb --- /dev/null +++ b/lib/src/middleware/logging_middleware.dart @@ -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; + } + }; + }; +} diff --git a/lib/src/middleware/rate_limit_middleware.dart b/lib/src/middleware/rate_limit_middleware.dart new file mode 100644 index 0000000..36640ec --- /dev/null +++ b/lib/src/middleware/rate_limit_middleware.dart @@ -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 = {}; + + 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); +} diff --git a/lib/src/modules/ai/ai_routes.dart b/lib/src/modules/ai/ai_routes.dart new file mode 100644 index 0000000..f6580db --- /dev/null +++ b/lib/src/modules/ai/ai_routes.dart @@ -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 _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; + + final ingredients = + (body['ingredients'] as List?)?.cast() ?? []; + final dietaryRestrictions = + (body['dietary_restrictions'] as List?)?.cast(); + final allergies = + (body['allergies'] as List?)?.cast(); + 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); + } +} diff --git a/lib/src/modules/ai/ai_service.dart b/lib/src/modules/ai/ai_service.dart new file mode 100644 index 0000000..1034092 --- /dev/null +++ b/lib/src/modules/ai/ai_service.dart @@ -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> generateRecipe({ + required List ingredients, + List? dietaryRestrictions, + List? 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 recipe; + try { + recipe = jsonDecode(cleaned) as Map; + } 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?)?.cast() ?? []; + + _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; + } +} diff --git a/lib/src/modules/ai/llm_client.dart b/lib/src/modules/ai/llm_client.dart new file mode 100644 index 0000000..aaddc4a --- /dev/null +++ b/lib/src/modules/ai/llm_client.dart @@ -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 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; + final content = json['content'] as List?; + if (content == null || content.isEmpty) { + throw ApiException.internal('Empty response from AI service'); + } + + final textBlock = content.firstWhere( + (block) => (block as Map)['type'] == 'text', + orElse: () => null, + ); + + if (textBlock == null) { + throw ApiException.internal('No text block in AI response'); + } + + return (textBlock as Map)['text'] as String; + } +} diff --git a/lib/src/modules/ai/prompt_templates.dart b/lib/src/modules/ai/prompt_templates.dart new file mode 100644 index 0000000..410cc61 --- /dev/null +++ b/lib/src/modules/ai/prompt_templates.dart @@ -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 ingredients, + List? dietaryRestrictions, + List? 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" + } + ] +}'''; +} diff --git a/lib/src/modules/auth/auth_routes.dart b/lib/src/modules/auth/auth_routes.dart new file mode 100644 index 0000000..20d96a6 --- /dev/null +++ b/lib/src/modules/auth/auth_routes.dart @@ -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 _register(Request request) async { + final body = jsonDecode(await request.readAsString()) as Map; + 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 _login(Request request) async { + final body = jsonDecode(await request.readAsString()) as Map; + 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 _refresh(Request request) async { + final body = jsonDecode(await request.readAsString()) as Map; + 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 _logout(Request request) async { + final body = jsonDecode(await request.readAsString()) as Map; + 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 _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(); + } +} diff --git a/lib/src/modules/auth/auth_service.dart b/lib/src/modules/auth/auth_service.dart new file mode 100644 index 0000000..462ace7 --- /dev/null +++ b/lib/src/modules/auth/auth_service.dart @@ -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> 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> 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> 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 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 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 _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, + }, + ); + } +} diff --git a/lib/src/modules/auth/password_hasher.dart b/lib/src/modules/auth/password_hasher.dart new file mode 100644 index 0000000..234e632 --- /dev/null +++ b/lib/src/modules/auth/password_hasher.dart @@ -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 `::`. +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.generate(_saltLength, (_) => random.nextInt(256))); + } + + /// Pure-Dart PBKDF2 with HMAC-SHA256. + static Uint8List _pbkdf2( + String password, List 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)); + } +} diff --git a/lib/src/modules/auth/token_service.dart b/lib/src/modules/auth/token_service.dart new file mode 100644 index 0000000..fb89d8d --- /dev/null +++ b/lib/src/modules/auth/token_service.dart @@ -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; + 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.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(); + } +} diff --git a/lib/src/modules/community/community_routes.dart b/lib/src/modules/community/community_routes.dart new file mode 100644 index 0000000..f04b335 --- /dev/null +++ b/lib/src/modules/community/community_routes.dart @@ -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('/', _deleteReview); + + return router; + } + + Future _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, + ); + } + + Future _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; + + 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 _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(); + } +} diff --git a/lib/src/modules/community/review_service.dart b/lib/src/modules/community/review_service.dart new file mode 100644 index 0000000..1b53b8a --- /dev/null +++ b/lib/src/modules/community/review_service.dart @@ -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> 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> 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 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'); + } +} diff --git a/lib/src/modules/media/media_routes.dart b/lib/src/modules/media/media_routes.dart new file mode 100644 index 0000000..f25ee6b --- /dev/null +++ b/lib/src/modules/media/media_routes.dart @@ -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 _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, + ); + } +} diff --git a/lib/src/modules/media/media_service.dart b/lib/src/modules/media/media_service.dart new file mode 100644 index 0000000..861a5c0 --- /dev/null +++ b/lib/src/modules/media/media_service.dart @@ -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> uploadImage({ + required String userId, + required List 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, + }; + } +} diff --git a/lib/src/modules/recipes/recipe_repository.dart b/lib/src/modules/recipes/recipe_repository.dart new file mode 100644 index 0000000..1a68fad --- /dev/null +++ b/lib/src/modules/recipes/recipe_repository.dart @@ -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 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 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 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 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 countRecipes({ + String? cuisine, + String? difficulty, + String? userId, + }) async { + final where = ['deleted_at IS NULL']; + final params = {}; + + 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 listRecipes({ + required int limit, + required int offset, + String? cuisine, + String? difficulty, + String? userId, + }) async { + final where = ['deleted_at IS NULL']; + final params = { + '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 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 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 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 updateRecipe({ + required String recipeId, + required String userId, + String? title, + String? description, + int? prepTime, + int? cookTime, + int? servings, + String? difficulty, + String? cuisine, + List? tags, + String? imageUrl, + }) async { + final sets = ['updated_at = NOW()']; + final params = { + '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 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 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 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 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 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}, + ); + } +} diff --git a/lib/src/modules/recipes/recipe_routes.dart b/lib/src/modules/recipes/recipe_routes.dart new file mode 100644 index 0000000..4683911 --- /dev/null +++ b/lib/src/modules/recipes/recipe_routes.dart @@ -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('/', _getRecipe); + + // Authenticated + router.post('/', _createRecipe); + router.put('/', _updateRecipe); + router.delete('/', _deleteRecipe); + + // Save / unsave + router.post('//save', _saveRecipe); + router.delete('//save', _unsaveRecipe); + + return router; + } + + // ------------------------------------------------------------------ + // Handlers + // ------------------------------------------------------------------ + + Future _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, + ); + } + + Future _getRecipe(Request request, String id) async { + final recipe = await RecipeService.getRecipeById(id); + return ApiResponse.success(recipe); + } + + Future _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; + + 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?)?.cast(), + imageUrl: body['image_url'] as String?, + isAiGenerated: body['is_ai_generated'] as bool? ?? false, + ingredients: + (body['ingredients'] as List?)?.cast>(), + steps: (body['steps'] as List?)?.cast>(), + ); + + return ApiResponse.created(recipe); + } + + Future _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; + + 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?)?.cast(), + imageUrl: body['image_url'] as String?, + ); + + return ApiResponse.success(recipe); + } + + Future _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 _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 _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 _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, + ); + } +} diff --git a/lib/src/modules/recipes/recipe_service.dart b/lib/src/modules/recipes/recipe_service.dart new file mode 100644 index 0000000..047daf2 --- /dev/null +++ b/lib/src/modules/recipes/recipe_service.dart @@ -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> createRecipe({ + required String userId, + required String title, + String? description, + int? prepTime, + int? cookTime, + int? servings, + String? difficulty, + String? cuisine, + List? tags, + String? imageUrl, + bool isAiGenerated = false, + List>? ingredients, + List>? 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> 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> 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> updateRecipe({ + required String recipeId, + required String userId, + String? title, + String? description, + int? prepTime, + int? cookTime, + int? servings, + String? difficulty, + String? cuisine, + List? 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 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 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 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> 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 _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(), + }; + } +} diff --git a/lib/src/modules/search/search_routes.dart b/lib/src/modules/search/search_routes.dart new file mode 100644 index 0000000..98ca60c --- /dev/null +++ b/lib/src/modules/search/search_routes.dart @@ -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 _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, + ); + } +} diff --git a/lib/src/modules/search/search_service.dart b/lib/src/modules/search/search_service.dart new file mode 100644 index 0000000..b9a9cad --- /dev/null +++ b/lib/src/modules/search/search_service.dart @@ -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> searchRecipes({ + required String query, + int limit = 20, + int offset = 0, + Map? filters, + }) async { + _log.info('Search query: "$query" (limit=$limit, offset=$offset)'); + // Placeholder — return empty results until Meilisearch is wired up. + return { + 'recipes': >[], + 'meta': { + 'query': query, + 'total_count': 0, + 'limit': limit, + 'offset': offset, + }, + }; + } + + /// Index a recipe document in Meilisearch after creation / update. + static Future indexRecipe(Map 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 removeRecipe(String recipeId) async { + _log.info('Removing recipe from index: $recipeId'); + // TODO: DELETE from Meilisearch index + } +} diff --git a/lib/src/modules/users/user_routes.dart b/lib/src/modules/users/user_routes.dart new file mode 100644 index 0000000..ba94f6b --- /dev/null +++ b/lib/src/modules/users/user_routes.dart @@ -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('/', _getProfile); + + return router; + } + + Future _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 _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; + + 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 _getProfile(Request request, String id) async { + final profile = await UserService.getProfile(id); + return ApiResponse.success(profile); + } +} diff --git a/lib/src/modules/users/user_service.dart b/lib/src/modules/users/user_service.dart new file mode 100644 index 0000000..c2f5f53 --- /dev/null +++ b/lib/src/modules/users/user_service.dart @@ -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> 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> updateProfile({ + required String userId, + String? username, + String? bio, + String? avatarUrl, + }) async { + final sets = ['updated_at = NOW()']; + final params = {'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); + } +} diff --git a/lib/src/shared/api_response.dart b/lib/src/shared/api_response.dart new file mode 100644 index 0000000..9dc4177 --- /dev/null +++ b/lib/src/shared/api_response.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'package:shelf/shelf.dart'; + +class ApiResponse { + static Response success(dynamic data, + {Map? 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); +} diff --git a/lib/src/shared/pagination.dart b/lib/src/shared/pagination.dart new file mode 100644 index 0000000..c431b2a --- /dev/null +++ b/lib/src/shared/pagination.dart @@ -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 toMeta(int totalCount) { + return { + 'page': page, + 'per_page': perPage, + 'total_count': totalCount, + 'total_pages': (totalCount / perPage).ceil(), + }; + } +} diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..bddba8d --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -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; diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..b169f4c --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d8c6aaa --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/server_test.dart b/test/server_test.dart new file mode 100644 index 0000000..3081d87 --- /dev/null +++ b/test/server_test.dart @@ -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); + }); +}