Initial commit: CleanPlate backend API
Dart Shelf REST API with auth, recipes, AI (Claude), search, and community modules. PostgreSQL, Redis, Meilisearch. Docker Compose for local dev.
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
build/
|
||||||
|
.dart_tool/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
.packages
|
||||||
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# CleanPlate API Environment Variables
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://cleanplate:cleanplate@localhost:5432/cleanplate
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
JWT_SECRET=change-this-to-a-secure-random-string-in-production
|
||||||
|
|
||||||
|
# Anthropic Claude API
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||||
|
|
||||||
|
# Meilisearch
|
||||||
|
MEILISEARCH_URL=http://localhost:7700
|
||||||
|
MEILI_MASTER_KEY=change-this-in-production
|
||||||
|
|
||||||
|
# MinIO (S3-compatible storage)
|
||||||
|
MINIO_ENDPOINT=localhost:9000
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# https://dart.dev/guides/libraries/private-files
|
||||||
|
# Created by `dart pub`
|
||||||
|
.dart_tool/
|
||||||
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## 1.0.0
|
||||||
|
|
||||||
|
- Initial version.
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Use latest stable channel SDK.
|
||||||
|
FROM dart:stable AS build
|
||||||
|
|
||||||
|
# Resolve app dependencies.
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pubspec.* ./
|
||||||
|
RUN dart pub get
|
||||||
|
|
||||||
|
# Copy app source code (except anything in .dockerignore) and AOT compile app.
|
||||||
|
COPY . .
|
||||||
|
RUN dart compile exe bin/server.dart -o bin/server
|
||||||
|
|
||||||
|
# Build minimal serving image from AOT-compiled `/server`
|
||||||
|
# and the pre-built AOT-runtime in the `/runtime/` directory of the base image.
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /runtime/ /
|
||||||
|
COPY --from=build /app/bin/server /app/bin/
|
||||||
|
|
||||||
|
# Start server.
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["/app/bin/server"]
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
A server app built using [Shelf](https://pub.dev/packages/shelf),
|
||||||
|
configured to enable running with [Docker](https://www.docker.com/).
|
||||||
|
|
||||||
|
This sample code handles HTTP GET requests to `/` and `/echo/<message>`
|
||||||
|
|
||||||
|
# Running the sample
|
||||||
|
|
||||||
|
## Running with the Dart SDK
|
||||||
|
|
||||||
|
You can run the example with the [Dart SDK](https://dart.dev/get-dart)
|
||||||
|
like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ dart run bin/server.dart
|
||||||
|
Server listening on port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
And then from a second terminal:
|
||||||
|
```
|
||||||
|
$ curl http://0.0.0.0:8080
|
||||||
|
Hello, World!
|
||||||
|
$ curl http://0.0.0.0:8080/echo/I_love_Dart
|
||||||
|
I_love_Dart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
If you have [Docker Desktop](https://www.docker.com/get-started) installed, you
|
||||||
|
can build and run with the `docker` command:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ docker build . -t myserver
|
||||||
|
$ docker run -it -p 8080:8080 myserver
|
||||||
|
Server listening on port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
And then from a second terminal:
|
||||||
|
```
|
||||||
|
$ curl http://0.0.0.0:8080
|
||||||
|
Hello, World!
|
||||||
|
$ curl http://0.0.0.0:8080/echo/I_love_Dart
|
||||||
|
I_love_Dart
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the logging printed in the first terminal:
|
||||||
|
```
|
||||||
|
2021-05-06T15:47:04.620417 0:00:00.000158 GET [200] /
|
||||||
|
2021-05-06T15:47:08.392928 0:00:00.001216 GET [200] /echo/I_love_Dart
|
||||||
|
```
|
||||||
30
analysis_options.yaml
Normal file
30
analysis_options.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This file configures the static analysis results for your project (errors,
|
||||||
|
# warnings, and lints).
|
||||||
|
#
|
||||||
|
# This enables the 'recommended' set of lints from `package:lints`.
|
||||||
|
# This set helps identify many issues that may lead to problems when running
|
||||||
|
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||||
|
# style and format.
|
||||||
|
#
|
||||||
|
# If you want a smaller set of lints you can change this to specify
|
||||||
|
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||||
|
# (the recommended set includes the core lints).
|
||||||
|
# The core lints are also what is used by pub.dev for scoring packages.
|
||||||
|
|
||||||
|
include: package:lints/recommended.yaml
|
||||||
|
|
||||||
|
# Uncomment the following section to specify additional rules.
|
||||||
|
|
||||||
|
# linter:
|
||||||
|
# rules:
|
||||||
|
# - camel_case_types
|
||||||
|
|
||||||
|
# analyzer:
|
||||||
|
# exclude:
|
||||||
|
# - path/to/excluded/files/**
|
||||||
|
|
||||||
|
# For more information about the core and recommended set of lints, see
|
||||||
|
# https://dart.dev/go/core-lints
|
||||||
|
|
||||||
|
# For additional information about configuring this file, see
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
145
bin/migrate.dart
Normal file
145
bin/migrate.dart
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:cleanplate_api/src/config/env.dart';
|
||||||
|
import 'package:cleanplate_api/src/config/database.dart';
|
||||||
|
|
||||||
|
final _log = Logger('Migrate');
|
||||||
|
|
||||||
|
/// Database migration runner.
|
||||||
|
///
|
||||||
|
/// Run with: dart run bin/migrate.dart
|
||||||
|
///
|
||||||
|
/// This applies all migrations in order. Each migration is idempotent
|
||||||
|
/// (uses IF NOT EXISTS) so it is safe to re-run.
|
||||||
|
void main(List<String> args) async {
|
||||||
|
Logger.root.level = Level.ALL;
|
||||||
|
Logger.root.onRecord.listen((record) {
|
||||||
|
stderr.writeln(
|
||||||
|
'${record.time} [${record.level.name}] ${record.loggerName}: ${record.message}');
|
||||||
|
});
|
||||||
|
|
||||||
|
_log.info('Starting migrations (env=${Env.environment})...');
|
||||||
|
|
||||||
|
await Database.initialize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < _migrations.length; i++) {
|
||||||
|
_log.info('Running migration ${i + 1}/${_migrations.length}: ${_migrationNames[i]}');
|
||||||
|
await Database.execute(_migrations[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.info('All migrations applied successfully');
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.severe('Migration failed', e, st);
|
||||||
|
exit(1);
|
||||||
|
} finally {
|
||||||
|
await Database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _migrationNames = [
|
||||||
|
'Create users table',
|
||||||
|
'Create refresh_tokens table',
|
||||||
|
'Create recipes table',
|
||||||
|
'Create ingredients table',
|
||||||
|
'Create steps table',
|
||||||
|
'Create saved_recipes table',
|
||||||
|
'Create reviews table',
|
||||||
|
];
|
||||||
|
|
||||||
|
const _migrations = [
|
||||||
|
// 1. Users
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
bio TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 2. Refresh tokens
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 3. Recipes
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
prep_time INTEGER,
|
||||||
|
cook_time INTEGER,
|
||||||
|
servings INTEGER,
|
||||||
|
difficulty TEXT,
|
||||||
|
cuisine TEXT,
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
image_url TEXT,
|
||||||
|
is_ai_generated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 4. Ingredients
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS ingredients (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
quantity TEXT,
|
||||||
|
unit TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 5. Steps
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS steps (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
step_number INTEGER NOT NULL,
|
||||||
|
instruction TEXT NOT NULL,
|
||||||
|
duration_minutes INTEGER
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 6. Saved recipes (bookmarks)
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS saved_recipes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id, recipe_id)
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
|
||||||
|
// 7. Reviews
|
||||||
|
'''
|
||||||
|
CREATE TABLE IF NOT EXISTS reviews (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
comment TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
];
|
||||||
147
bin/server.dart
Normal file
147
bin/server.dart
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import 'package:cleanplate_api/src/config/env.dart';
|
||||||
|
import 'package:cleanplate_api/src/config/database.dart';
|
||||||
|
import 'package:cleanplate_api/src/config/redis_config.dart';
|
||||||
|
|
||||||
|
import 'package:cleanplate_api/src/middleware/auth_middleware.dart';
|
||||||
|
import 'package:cleanplate_api/src/middleware/cors_middleware.dart';
|
||||||
|
import 'package:cleanplate_api/src/middleware/logging_middleware.dart';
|
||||||
|
import 'package:cleanplate_api/src/middleware/error_handler.dart';
|
||||||
|
import 'package:cleanplate_api/src/middleware/rate_limit_middleware.dart';
|
||||||
|
|
||||||
|
import 'package:cleanplate_api/src/modules/auth/auth_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/recipes/recipe_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/ai/ai_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/search/search_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/media/media_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/users/user_routes.dart';
|
||||||
|
import 'package:cleanplate_api/src/modules/community/community_routes.dart';
|
||||||
|
|
||||||
|
final _log = Logger('Server');
|
||||||
|
|
||||||
|
void main(List<String> args) async {
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Logging
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
Logger.root.level = Env.isProduction ? Level.INFO : Level.ALL;
|
||||||
|
Logger.root.onRecord.listen((record) {
|
||||||
|
stderr.writeln(
|
||||||
|
'${record.time} [${record.level.name}] ${record.loggerName}: ${record.message}');
|
||||||
|
if (record.error != null) stderr.writeln(' Error: ${record.error}');
|
||||||
|
if (record.stackTrace != null) {
|
||||||
|
stderr.writeln(' Stack: ${record.stackTrace}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Infrastructure connections
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
try {
|
||||||
|
await Database.initialize();
|
||||||
|
_log.info('Database connected');
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('Could not connect to database: $e');
|
||||||
|
_log.warning('Server will start but database-dependent routes will fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await RedisConfig.initialize();
|
||||||
|
_log.info('Redis connected');
|
||||||
|
} catch (e) {
|
||||||
|
_log.warning('Could not connect to Redis: $e');
|
||||||
|
_log.warning('Server will start but rate-limiting may use in-memory fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Routers
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
final authRoutes = AuthRoutes();
|
||||||
|
final recipeRoutes = RecipeRoutes();
|
||||||
|
final aiRoutes = AiRoutes();
|
||||||
|
final searchRoutes = SearchRoutes();
|
||||||
|
final mediaRoutes = MediaRoutes();
|
||||||
|
final userRoutes = UserRoutes();
|
||||||
|
final communityRoutes = CommunityRoutes();
|
||||||
|
|
||||||
|
// Top-level router.
|
||||||
|
final app = Router();
|
||||||
|
|
||||||
|
// Health check.
|
||||||
|
app.get('/health', (Request request) {
|
||||||
|
return Response.ok(
|
||||||
|
jsonEncode({'status': 'ok'}),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount module routers under /api/v1.
|
||||||
|
app.mount('/api/v1/auth/', authRoutes.router.call);
|
||||||
|
app.mount('/api/v1/recipes/', recipeRoutes.router.call);
|
||||||
|
app.mount('/api/v1/ai/', aiRoutes.router.call);
|
||||||
|
app.mount('/api/v1/search/', searchRoutes.router.call);
|
||||||
|
app.mount('/api/v1/media/', mediaRoutes.router.call);
|
||||||
|
app.mount('/api/v1/users/', userRoutes.router.call);
|
||||||
|
|
||||||
|
// Community reviews are nested under a recipe.
|
||||||
|
// We use a small adapter to inject the recipeId into the request context.
|
||||||
|
app.mount('/api/v1/recipes/<recipeId>/reviews/', (Request request) {
|
||||||
|
final recipeId = request.params['recipeId'];
|
||||||
|
final updated = request.change(context: {'recipeId': recipeId});
|
||||||
|
return communityRoutes.router.call(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Middleware pipeline
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
final handler = Pipeline()
|
||||||
|
.addMiddleware(corsMiddleware())
|
||||||
|
.addMiddleware(loggingMiddleware())
|
||||||
|
.addMiddleware(errorHandler())
|
||||||
|
.addMiddleware(rateLimitMiddleware())
|
||||||
|
.addMiddleware(authMiddleware())
|
||||||
|
.addHandler(app.call);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Start server
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
final ip = InternetAddress.anyIPv4;
|
||||||
|
final port = int.parse(Env.port);
|
||||||
|
final server = await shelf_io.serve(handler, ip, port);
|
||||||
|
|
||||||
|
_log.info('CleanPlate API listening on http://${server.address.host}:${server.port}');
|
||||||
|
_log.info('Environment: ${Env.environment}');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Graceful shutdown
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
ProcessSignal.sigint.watch().listen((_) async {
|
||||||
|
_log.info('SIGINT received — shutting down...');
|
||||||
|
await _shutdown(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SIGTERM is not available on Windows but fine on Linux/macOS.
|
||||||
|
if (!Platform.isWindows) {
|
||||||
|
ProcessSignal.sigterm.watch().listen((_) async {
|
||||||
|
_log.info('SIGTERM received — shutting down...');
|
||||||
|
await _shutdown(server);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shutdown(HttpServer server) async {
|
||||||
|
_log.info('Closing HTTP server...');
|
||||||
|
await server.close(force: false);
|
||||||
|
_log.info('Closing database pool...');
|
||||||
|
await Database.close();
|
||||||
|
_log.info('Closing Redis connection...');
|
||||||
|
await RedisConfig.close();
|
||||||
|
_log.info('Shutdown complete');
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
114
docker-compose.yml
Normal file
114
docker-compose.yml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
services:
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- DATABASE_URL=postgresql://cleanplate:cleanplate@postgres:5432/cleanplate
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-dev-secret-change-in-production}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||||
|
- MEILISEARCH_URL=http://meilisearch:7700
|
||||||
|
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-dev-meili-key}
|
||||||
|
- MINIO_ENDPOINT=minio:9000
|
||||||
|
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
- APP_ENV=development
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
meilisearch:
|
||||||
|
condition: service_started
|
||||||
|
minio:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: cleanplate
|
||||||
|
POSTGRES_USER: cleanplate
|
||||||
|
POSTGRES_PASSWORD: cleanplate
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./migrations:/docker-entrypoint-initdb.d
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U cleanplate -d cleanplate"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.11
|
||||||
|
ports:
|
||||||
|
- "7700:7700"
|
||||||
|
environment:
|
||||||
|
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-key}
|
||||||
|
MEILI_ENV: development
|
||||||
|
volumes:
|
||||||
|
- meili_data:/meili_data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# MinIO bucket initialization
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc
|
||||||
|
depends_on:
|
||||||
|
- minio
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
sleep 5;
|
||||||
|
mc alias set local http://minio:9000 minioadmin minioadmin;
|
||||||
|
mc mb --ignore-existing local/cleanplate-images;
|
||||||
|
mc mb --ignore-existing local/cleanplate-videos;
|
||||||
|
mc mb --ignore-existing local/cleanplate-temp;
|
||||||
|
mc anonymous set download local/cleanplate-images;
|
||||||
|
mc anonymous set download local/cleanplate-videos;
|
||||||
|
echo 'Buckets created successfully';
|
||||||
|
"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
redis_data:
|
||||||
|
meili_data:
|
||||||
|
minio_data:
|
||||||
86
lib/src/config/database.dart
Normal file
86
lib/src/config/database.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'env.dart';
|
||||||
|
|
||||||
|
final _log = Logger('Database');
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
static Pool? _pool;
|
||||||
|
|
||||||
|
/// Initialize the connection pool from the DATABASE_URL environment variable.
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_pool != null) return;
|
||||||
|
|
||||||
|
final uri = Uri.parse(Env.databaseUrl);
|
||||||
|
final endpoint = Endpoint(
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.port,
|
||||||
|
database: uri.pathSegments.isNotEmpty ? uri.pathSegments.first : 'cleanplate',
|
||||||
|
username: uri.userInfo.contains(':')
|
||||||
|
? uri.userInfo.split(':').first
|
||||||
|
: uri.userInfo,
|
||||||
|
password: uri.userInfo.contains(':')
|
||||||
|
? uri.userInfo.split(':').last
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
_pool = Pool.withEndpoints(
|
||||||
|
[endpoint],
|
||||||
|
settings: PoolSettings(
|
||||||
|
maxConnectionCount: 10,
|
||||||
|
sslMode: SslMode.disable,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info('Database pool initialized (host=${uri.host}, db=${uri.pathSegments.isNotEmpty ? uri.pathSegments.first : "cleanplate"})');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a statement that does not return rows (INSERT, UPDATE, DELETE, DDL).
|
||||||
|
static Future<Result> execute(
|
||||||
|
String sql, {
|
||||||
|
Map<String, dynamic>? parameters,
|
||||||
|
}) async {
|
||||||
|
_ensureInitialized();
|
||||||
|
return _pool!.execute(
|
||||||
|
Sql.named(sql),
|
||||||
|
parameters: parameters ?? {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a query and return the result rows.
|
||||||
|
static Future<Result> query(
|
||||||
|
String sql, {
|
||||||
|
Map<String, dynamic>? parameters,
|
||||||
|
}) async {
|
||||||
|
_ensureInitialized();
|
||||||
|
return _pool!.execute(
|
||||||
|
Sql.named(sql),
|
||||||
|
parameters: parameters ?? {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run multiple statements inside a single transaction.
|
||||||
|
static Future<T> transaction<T>(
|
||||||
|
Future<T> Function(TxSession tx) action,
|
||||||
|
) async {
|
||||||
|
_ensureInitialized();
|
||||||
|
return _pool!.runTx(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close all connections in the pool.
|
||||||
|
static Future<void> close() async {
|
||||||
|
if (_pool != null) {
|
||||||
|
await _pool!.close();
|
||||||
|
_pool = null;
|
||||||
|
_log.info('Database pool closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _ensureInitialized() {
|
||||||
|
if (_pool == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Database not initialized. Call Database.initialize() first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/src/config/env.dart
Normal file
27
lib/src/config/env.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class Env {
|
||||||
|
static String get port => Platform.environment['PORT'] ?? '8080';
|
||||||
|
static String get databaseUrl =>
|
||||||
|
Platform.environment['DATABASE_URL'] ??
|
||||||
|
'postgresql://cleanplate:cleanplate@localhost:5432/cleanplate';
|
||||||
|
static String get redisUrl =>
|
||||||
|
Platform.environment['REDIS_URL'] ?? 'redis://localhost:6379';
|
||||||
|
static String get jwtSecret =>
|
||||||
|
Platform.environment['JWT_SECRET'] ?? 'dev-secret-change-in-production';
|
||||||
|
static String get anthropicApiKey =>
|
||||||
|
Platform.environment['ANTHROPIC_API_KEY'] ?? '';
|
||||||
|
static String get meilisearchUrl =>
|
||||||
|
Platform.environment['MEILISEARCH_URL'] ?? 'http://localhost:7700';
|
||||||
|
static String get meilisearchKey =>
|
||||||
|
Platform.environment['MEILI_MASTER_KEY'] ?? '';
|
||||||
|
static String get minioEndpoint =>
|
||||||
|
Platform.environment['MINIO_ENDPOINT'] ?? 'localhost:9000';
|
||||||
|
static String get minioAccessKey =>
|
||||||
|
Platform.environment['MINIO_ACCESS_KEY'] ?? 'minioadmin';
|
||||||
|
static String get minioSecretKey =>
|
||||||
|
Platform.environment['MINIO_SECRET_KEY'] ?? 'minioadmin';
|
||||||
|
static String get environment =>
|
||||||
|
Platform.environment['APP_ENV'] ?? 'development';
|
||||||
|
static bool get isProduction => environment == 'production';
|
||||||
|
}
|
||||||
43
lib/src/config/redis_config.dart
Normal file
43
lib/src/config/redis_config.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:redis/redis.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'env.dart';
|
||||||
|
|
||||||
|
final _log = Logger('RedisConfig');
|
||||||
|
|
||||||
|
class RedisConfig {
|
||||||
|
static RedisConnection? _connection;
|
||||||
|
static Command? _command;
|
||||||
|
|
||||||
|
/// Connect to Redis using the REDIS_URL environment variable.
|
||||||
|
static Future<void> initialize() async {
|
||||||
|
if (_command != null) return;
|
||||||
|
|
||||||
|
final uri = Uri.parse(Env.redisUrl);
|
||||||
|
final host = uri.host.isNotEmpty ? uri.host : 'localhost';
|
||||||
|
final port = uri.port != 0 ? uri.port : 6379;
|
||||||
|
|
||||||
|
_connection = RedisConnection();
|
||||||
|
_command = await _connection!.connect(host, port);
|
||||||
|
_log.info('Redis connected ($host:$port)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the active Redis command interface.
|
||||||
|
static Command get command {
|
||||||
|
if (_command == null) {
|
||||||
|
throw StateError(
|
||||||
|
'Redis not initialized. Call RedisConfig.initialize() first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _command!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the Redis connection.
|
||||||
|
static Future<void> close() async {
|
||||||
|
if (_connection != null) {
|
||||||
|
await _connection!.close();
|
||||||
|
_connection = null;
|
||||||
|
_command = null;
|
||||||
|
_log.info('Redis connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/src/middleware/auth_middleware.dart
Normal file
81
lib/src/middleware/auth_middleware.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import '../modules/auth/token_service.dart';
|
||||||
|
import '../shared/api_response.dart';
|
||||||
|
|
||||||
|
/// Routes that do not require authentication.
|
||||||
|
const _publicRoutes = <_RoutePattern>[
|
||||||
|
_RoutePattern('POST', '/api/v1/auth/register'),
|
||||||
|
_RoutePattern('POST', '/api/v1/auth/login'),
|
||||||
|
_RoutePattern('POST', '/api/v1/auth/refresh'),
|
||||||
|
_RoutePattern('GET', '/health'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A lightweight path-prefix check so GET /api/v1/recipes and
|
||||||
|
/// GET /api/v1/recipes/`<id>` are both public.
|
||||||
|
const _publicPrefixes = <_PrefixPattern>[
|
||||||
|
_PrefixPattern('GET', '/api/v1/recipes'),
|
||||||
|
_PrefixPattern('GET', '/api/v1/search'),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _RoutePattern {
|
||||||
|
final String method;
|
||||||
|
final String path;
|
||||||
|
const _RoutePattern(this.method, this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrefixPattern {
|
||||||
|
final String method;
|
||||||
|
final String prefix;
|
||||||
|
const _PrefixPattern(this.method, this.prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shelf middleware that verifies a JWT Bearer token on protected routes.
|
||||||
|
///
|
||||||
|
/// On success, the userId is stored in the request context under the key
|
||||||
|
/// `userId` so downstream handlers can access it via
|
||||||
|
/// `request.context['userId']`.
|
||||||
|
Middleware authMiddleware() {
|
||||||
|
return (Handler innerHandler) {
|
||||||
|
return (Request request) async {
|
||||||
|
final method = request.method;
|
||||||
|
final path = '/${request.url.path}';
|
||||||
|
|
||||||
|
// Check exact public routes.
|
||||||
|
for (final route in _publicRoutes) {
|
||||||
|
if (route.method == method && route.path == path) {
|
||||||
|
return innerHandler(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check public prefixes.
|
||||||
|
for (final prefix in _publicPrefixes) {
|
||||||
|
if (prefix.method == method && path.startsWith(prefix.prefix)) {
|
||||||
|
return innerHandler(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract Bearer token.
|
||||||
|
final authHeader = request.headers['authorization'];
|
||||||
|
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return ApiResponse.unauthorized('Missing or malformed Authorization header');
|
||||||
|
}
|
||||||
|
|
||||||
|
final token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final userId = TokenService.verifyAccessToken(token);
|
||||||
|
if (userId == null) {
|
||||||
|
return ApiResponse.unauthorized('Invalid or expired token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add userId to request context.
|
||||||
|
final updatedRequest = request.change(
|
||||||
|
context: {'userId': userId},
|
||||||
|
);
|
||||||
|
return innerHandler(updatedRequest);
|
||||||
|
} catch (_) {
|
||||||
|
return ApiResponse.unauthorized('Invalid or expired token');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
15
lib/src/middleware/cors_middleware.dart
Normal file
15
lib/src/middleware/cors_middleware.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
|
||||||
|
|
||||||
|
/// Returns a CORS middleware with permissive defaults suitable for development.
|
||||||
|
Middleware corsMiddleware() {
|
||||||
|
return corsHeaders(
|
||||||
|
headers: {
|
||||||
|
ACCESS_CONTROL_ALLOW_ORIGIN: '*',
|
||||||
|
ACCESS_CONTROL_ALLOW_METHODS: 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||||
|
ACCESS_CONTROL_ALLOW_HEADERS:
|
||||||
|
'Origin, Content-Type, Accept, Authorization, X-Requested-With',
|
||||||
|
ACCESS_CONTROL_MAX_AGE: '86400',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
90
lib/src/middleware/error_handler.dart
Normal file
90
lib/src/middleware/error_handler.dart
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('ErrorHandler');
|
||||||
|
|
||||||
|
/// Global error-handling middleware.
|
||||||
|
///
|
||||||
|
/// Catches synchronous and asynchronous exceptions thrown by inner handlers
|
||||||
|
/// and converts them to structured JSON error responses.
|
||||||
|
Middleware errorHandler() {
|
||||||
|
return (Handler innerHandler) {
|
||||||
|
return (Request request) async {
|
||||||
|
try {
|
||||||
|
final response = await innerHandler(request);
|
||||||
|
return response;
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
_log.warning('API error: ${e.code} - ${e.message}');
|
||||||
|
return Response(e.statusCode,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'error',
|
||||||
|
'error': {
|
||||||
|
'code': e.code,
|
||||||
|
'message': e.message,
|
||||||
|
if (e.details != null) 'details': e.details,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
_log.warning('Bad request: $e');
|
||||||
|
return Response(400,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'error',
|
||||||
|
'error': {
|
||||||
|
'code': 'BAD_REQUEST',
|
||||||
|
'message': 'Invalid request format: ${e.message}',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.severe('Unhandled exception', e, st);
|
||||||
|
return Response.internalServerError(
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'error',
|
||||||
|
'error': {
|
||||||
|
'code': 'INTERNAL_ERROR',
|
||||||
|
'message': 'Internal server error',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A typed exception that the error handler can inspect to produce a proper
|
||||||
|
/// HTTP status and machine-readable error code.
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final int statusCode;
|
||||||
|
final String code;
|
||||||
|
final String message;
|
||||||
|
final dynamic details;
|
||||||
|
|
||||||
|
ApiException(this.statusCode, this.code, this.message, {this.details});
|
||||||
|
|
||||||
|
factory ApiException.badRequest(String message, {dynamic details}) =>
|
||||||
|
ApiException(400, 'BAD_REQUEST', message, details: details);
|
||||||
|
|
||||||
|
factory ApiException.unauthorized([String message = 'Unauthorized']) =>
|
||||||
|
ApiException(401, 'UNAUTHORIZED', message);
|
||||||
|
|
||||||
|
factory ApiException.forbidden([String message = 'Forbidden']) =>
|
||||||
|
ApiException(403, 'FORBIDDEN', message);
|
||||||
|
|
||||||
|
factory ApiException.notFound([String message = 'Not found']) =>
|
||||||
|
ApiException(404, 'NOT_FOUND', message);
|
||||||
|
|
||||||
|
factory ApiException.conflict(String message) =>
|
||||||
|
ApiException(409, 'CONFLICT', message);
|
||||||
|
|
||||||
|
factory ApiException.tooManyRequests([String message = 'Too many requests']) =>
|
||||||
|
ApiException(429, 'TOO_MANY_REQUESTS', message);
|
||||||
|
|
||||||
|
factory ApiException.internal([String message = 'Internal server error']) =>
|
||||||
|
ApiException(500, 'INTERNAL_ERROR', message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'ApiException($statusCode, $code, $message)';
|
||||||
|
}
|
||||||
35
lib/src/middleware/logging_middleware.dart
Normal file
35
lib/src/middleware/logging_middleware.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('HTTP');
|
||||||
|
|
||||||
|
/// Shelf middleware that logs incoming requests and outgoing responses with
|
||||||
|
/// timing information.
|
||||||
|
Middleware loggingMiddleware() {
|
||||||
|
return (Handler innerHandler) {
|
||||||
|
return (Request request) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final method = request.method;
|
||||||
|
final path = request.requestedUri.path;
|
||||||
|
|
||||||
|
_log.info('--> $method $path');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await innerHandler(request);
|
||||||
|
stopwatch.stop();
|
||||||
|
_log.info(
|
||||||
|
'<-- $method $path ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)',
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
} catch (e, st) {
|
||||||
|
stopwatch.stop();
|
||||||
|
_log.severe(
|
||||||
|
'<-- $method $path ERROR (${stopwatch.elapsedMilliseconds}ms)',
|
||||||
|
e,
|
||||||
|
st,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
64
lib/src/middleware/rate_limit_middleware.dart
Normal file
64
lib/src/middleware/rate_limit_middleware.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('RateLimit');
|
||||||
|
|
||||||
|
/// Simple in-memory rate limiter.
|
||||||
|
///
|
||||||
|
/// Tracks request counts per IP address with a sliding window. In production
|
||||||
|
/// this should be backed by Redis for multi-instance consistency.
|
||||||
|
Middleware rateLimitMiddleware({
|
||||||
|
int maxRequests = 100,
|
||||||
|
Duration window = const Duration(minutes: 1),
|
||||||
|
}) {
|
||||||
|
final buckets = <String, _Bucket>{};
|
||||||
|
|
||||||
|
return (Handler innerHandler) {
|
||||||
|
return (Request request) async {
|
||||||
|
final ip = request.headers['x-forwarded-for']?.split(',').first.trim() ??
|
||||||
|
request.headers['x-real-ip'] ??
|
||||||
|
'unknown';
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final bucket = buckets.putIfAbsent(ip, () => _Bucket(now));
|
||||||
|
|
||||||
|
// Reset if the window has elapsed.
|
||||||
|
if (now.difference(bucket.windowStart) > window) {
|
||||||
|
bucket.windowStart = now;
|
||||||
|
bucket.count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bucket.count++;
|
||||||
|
|
||||||
|
if (bucket.count > maxRequests) {
|
||||||
|
_log.warning('Rate limit exceeded for $ip');
|
||||||
|
return Response(429,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'retry-after': window.inSeconds.toString(),
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'error',
|
||||||
|
'error': {
|
||||||
|
'code': 'TOO_MANY_REQUESTS',
|
||||||
|
'message': 'Rate limit exceeded. Try again later.',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
final remaining = maxRequests - bucket.count;
|
||||||
|
final response = await innerHandler(request);
|
||||||
|
return response.change(headers: {
|
||||||
|
'x-ratelimit-limit': '$maxRequests',
|
||||||
|
'x-ratelimit-remaining': '$remaining',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Bucket {
|
||||||
|
DateTime windowStart;
|
||||||
|
int count = 0;
|
||||||
|
_Bucket(this.windowStart);
|
||||||
|
}
|
||||||
48
lib/src/modules/ai/ai_routes.dart
Normal file
48
lib/src/modules/ai/ai_routes.dart
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import 'ai_service.dart';
|
||||||
|
|
||||||
|
class AiRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
router.post('/generate', _generateRecipe);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _generateRecipe(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final body =
|
||||||
|
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final ingredients =
|
||||||
|
(body['ingredients'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||||
|
final dietaryRestrictions =
|
||||||
|
(body['dietary_restrictions'] as List<dynamic>?)?.cast<String>();
|
||||||
|
final allergies =
|
||||||
|
(body['allergies'] as List<dynamic>?)?.cast<String>();
|
||||||
|
final mood = body['mood'] as String?;
|
||||||
|
final maxTimeMinutes = body['max_time_minutes'] as int?;
|
||||||
|
final servings = body['servings'] as int?;
|
||||||
|
final skillLevel = body['skill_level'] as String?;
|
||||||
|
final cuisine = body['cuisine'] as String?;
|
||||||
|
|
||||||
|
final recipe = await AiService.generateRecipe(
|
||||||
|
ingredients: ingredients,
|
||||||
|
dietaryRestrictions: dietaryRestrictions,
|
||||||
|
allergies: allergies,
|
||||||
|
mood: mood,
|
||||||
|
maxTimeMinutes: maxTimeMinutes,
|
||||||
|
servings: servings,
|
||||||
|
skillLevel: skillLevel,
|
||||||
|
cuisine: cuisine,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
lib/src/modules/ai/ai_service.dart
Normal file
105
lib/src/modules/ai/ai_service.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
import 'llm_client.dart';
|
||||||
|
import 'prompt_templates.dart';
|
||||||
|
|
||||||
|
final _log = Logger('AiService');
|
||||||
|
|
||||||
|
class AiService {
|
||||||
|
/// Generate a recipe from the given ingredients and preferences.
|
||||||
|
///
|
||||||
|
/// Calls the LLM, parses the structured JSON response, validates it, and
|
||||||
|
/// returns a recipe map ready to be persisted or returned to the client.
|
||||||
|
static Future<Map<String, dynamic>> generateRecipe({
|
||||||
|
required List<String> ingredients,
|
||||||
|
List<String>? dietaryRestrictions,
|
||||||
|
List<String>? allergies,
|
||||||
|
String? mood,
|
||||||
|
int? maxTimeMinutes,
|
||||||
|
int? servings,
|
||||||
|
String? skillLevel,
|
||||||
|
String? cuisine,
|
||||||
|
}) async {
|
||||||
|
if (ingredients.isEmpty) {
|
||||||
|
throw ApiException.badRequest(
|
||||||
|
'At least one ingredient is required to generate a recipe');
|
||||||
|
}
|
||||||
|
|
||||||
|
final prompt = PromptTemplates.recipeGeneration(
|
||||||
|
ingredients: ingredients,
|
||||||
|
dietaryRestrictions: dietaryRestrictions,
|
||||||
|
allergies: allergies,
|
||||||
|
mood: mood,
|
||||||
|
maxTimeMinutes: maxTimeMinutes,
|
||||||
|
servings: servings,
|
||||||
|
skillLevel: skillLevel,
|
||||||
|
cuisine: cuisine,
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info(
|
||||||
|
'Generating recipe for ${ingredients.length} ingredients');
|
||||||
|
|
||||||
|
final rawResponse = await LlmClient.complete(
|
||||||
|
prompt: prompt,
|
||||||
|
temperature: 0.7,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The model may sometimes wrap JSON in markdown fences; strip them.
|
||||||
|
final cleaned = _stripMarkdownFences(rawResponse).trim();
|
||||||
|
|
||||||
|
Map<String, dynamic> recipe;
|
||||||
|
try {
|
||||||
|
recipe = jsonDecode(cleaned) as Map<String, dynamic>;
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
_log.warning('Failed to parse LLM JSON output: $e');
|
||||||
|
throw ApiException.internal(
|
||||||
|
'AI returned invalid JSON. Please try again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation.
|
||||||
|
if (recipe['title'] == null || (recipe['title'] as String).isEmpty) {
|
||||||
|
throw ApiException.internal('AI response missing recipe title');
|
||||||
|
}
|
||||||
|
if (recipe['ingredients'] == null ||
|
||||||
|
(recipe['ingredients'] as List).isEmpty) {
|
||||||
|
throw ApiException.internal('AI response missing ingredients');
|
||||||
|
}
|
||||||
|
if (recipe['steps'] == null || (recipe['steps'] as List).isEmpty) {
|
||||||
|
throw ApiException.internal('AI response missing steps');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise types.
|
||||||
|
recipe['prep_time'] = _toInt(recipe['prep_time']);
|
||||||
|
recipe['cook_time'] = _toInt(recipe['cook_time']);
|
||||||
|
recipe['servings'] = _toInt(recipe['servings']);
|
||||||
|
recipe['is_ai_generated'] = true;
|
||||||
|
recipe['tags'] =
|
||||||
|
(recipe['tags'] as List<dynamic>?)?.cast<String>() ?? <String>[];
|
||||||
|
|
||||||
|
_log.info('Recipe generated: ${recipe['title']}');
|
||||||
|
return recipe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove ``` or ```json fences.
|
||||||
|
static String _stripMarkdownFences(String text) {
|
||||||
|
var s = text.trim();
|
||||||
|
if (s.startsWith('```')) {
|
||||||
|
final firstNewline = s.indexOf('\n');
|
||||||
|
if (firstNewline != -1) {
|
||||||
|
s = s.substring(firstNewline + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.endsWith('```')) {
|
||||||
|
s = s.substring(0, s.length - 3);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _toInt(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/src/modules/ai/llm_client.dart
Normal file
86
lib/src/modules/ai/llm_client.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import '../../config/env.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
|
||||||
|
final _log = Logger('LlmClient');
|
||||||
|
|
||||||
|
/// HTTP client wrapper for the Anthropic Claude Messages API.
|
||||||
|
class LlmClient {
|
||||||
|
static const _baseUrl = 'https://api.anthropic.com/v1/messages';
|
||||||
|
static const _anthropicVersion = '2023-06-01';
|
||||||
|
static const _defaultModel = 'claude-sonnet-4-20250514';
|
||||||
|
static const _defaultMaxTokens = 4096;
|
||||||
|
static const _timeout = Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// Send a prompt to the Claude API and return the text response.
|
||||||
|
static Future<String> complete({
|
||||||
|
required String prompt,
|
||||||
|
String model = _defaultModel,
|
||||||
|
int maxTokens = _defaultMaxTokens,
|
||||||
|
double? temperature,
|
||||||
|
}) async {
|
||||||
|
final apiKey = Env.anthropicApiKey;
|
||||||
|
if (apiKey.isEmpty) {
|
||||||
|
throw ApiException.internal(
|
||||||
|
'Anthropic API key is not configured. Set ANTHROPIC_API_KEY.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final requestBody = {
|
||||||
|
'model': model,
|
||||||
|
'max_tokens': maxTokens,
|
||||||
|
'messages': [
|
||||||
|
{'role': 'user', 'content': prompt},
|
||||||
|
],
|
||||||
|
if (temperature != null) 'temperature': temperature,
|
||||||
|
};
|
||||||
|
|
||||||
|
_log.info('Calling Claude API (model=$model, maxTokens=$maxTokens)');
|
||||||
|
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse(_baseUrl),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': _anthropicVersion,
|
||||||
|
},
|
||||||
|
body: jsonEncode(requestBody),
|
||||||
|
)
|
||||||
|
.timeout(_timeout);
|
||||||
|
} on Exception catch (e) {
|
||||||
|
_log.severe('Claude API request failed', e);
|
||||||
|
throw ApiException.internal('Failed to reach AI service: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
_log.severe(
|
||||||
|
'Claude API error: ${response.statusCode} ${response.body}');
|
||||||
|
throw ApiException(
|
||||||
|
502,
|
||||||
|
'AI_SERVICE_ERROR',
|
||||||
|
'AI service returned status ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final content = json['content'] as List<dynamic>?;
|
||||||
|
if (content == null || content.isEmpty) {
|
||||||
|
throw ApiException.internal('Empty response from AI service');
|
||||||
|
}
|
||||||
|
|
||||||
|
final textBlock = content.firstWhere(
|
||||||
|
(block) => (block as Map<String, dynamic>)['type'] == 'text',
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textBlock == null) {
|
||||||
|
throw ApiException.internal('No text block in AI response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (textBlock as Map<String, dynamic>)['text'] as String;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/src/modules/ai/prompt_templates.dart
Normal file
99
lib/src/modules/ai/prompt_templates.dart
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/// Prompt templates for the AI recipe generation feature.
|
||||||
|
class PromptTemplates {
|
||||||
|
/// Build a recipe-generation prompt from user inputs.
|
||||||
|
///
|
||||||
|
/// The prompt instructs the LLM to return a JSON object that conforms to a
|
||||||
|
/// strict schema so we can parse it programmatically.
|
||||||
|
static String recipeGeneration({
|
||||||
|
required List<String> ingredients,
|
||||||
|
List<String>? dietaryRestrictions,
|
||||||
|
List<String>? allergies,
|
||||||
|
String? mood,
|
||||||
|
int? maxTimeMinutes,
|
||||||
|
int? servings,
|
||||||
|
String? skillLevel,
|
||||||
|
String? cuisine,
|
||||||
|
}) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
buffer.writeln('You are a professional chef and recipe developer.');
|
||||||
|
buffer.writeln('Create a detailed, original recipe based on the following requirements.');
|
||||||
|
buffer.writeln();
|
||||||
|
buffer.writeln('## Available Ingredients');
|
||||||
|
buffer.writeln(ingredients.map((i) => '- $i').join('\n'));
|
||||||
|
buffer.writeln();
|
||||||
|
|
||||||
|
if (dietaryRestrictions != null && dietaryRestrictions.isNotEmpty) {
|
||||||
|
buffer.writeln('## Dietary Restrictions');
|
||||||
|
buffer.writeln(dietaryRestrictions.map((d) => '- $d').join('\n'));
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allergies != null && allergies.isNotEmpty) {
|
||||||
|
buffer.writeln('## Allergies (MUST avoid these ingredients entirely)');
|
||||||
|
buffer.writeln(allergies.map((a) => '- $a').join('\n'));
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mood != null) {
|
||||||
|
buffer.writeln('## Mood / Vibe');
|
||||||
|
buffer.writeln(mood);
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxTimeMinutes != null) {
|
||||||
|
buffer.writeln('## Maximum Total Time');
|
||||||
|
buffer.writeln('$maxTimeMinutes minutes (prep + cook combined)');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servings != null) {
|
||||||
|
buffer.writeln('## Servings');
|
||||||
|
buffer.writeln('$servings');
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillLevel != null) {
|
||||||
|
buffer.writeln('## Skill Level');
|
||||||
|
buffer.writeln(skillLevel);
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cuisine != null) {
|
||||||
|
buffer.writeln('## Preferred Cuisine');
|
||||||
|
buffer.writeln(cuisine);
|
||||||
|
buffer.writeln();
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.writeln('## Output Format');
|
||||||
|
buffer.writeln('Return ONLY valid JSON matching this schema (no markdown fences):');
|
||||||
|
buffer.writeln(_jsonSchema);
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _jsonSchema = '''
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"description": "string (1-2 sentences)",
|
||||||
|
"prep_time": "integer (minutes)",
|
||||||
|
"cook_time": "integer (minutes)",
|
||||||
|
"servings": "integer",
|
||||||
|
"difficulty": "easy | medium | hard",
|
||||||
|
"cuisine": "string",
|
||||||
|
"tags": ["string"],
|
||||||
|
"ingredients": [
|
||||||
|
{
|
||||||
|
"name": "string",
|
||||||
|
"quantity": "string",
|
||||||
|
"unit": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"instruction": "string",
|
||||||
|
"duration_minutes": "integer or null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}''';
|
||||||
|
}
|
||||||
90
lib/src/modules/auth/auth_routes.dart
Normal file
90
lib/src/modules/auth/auth_routes.dart
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import 'auth_service.dart';
|
||||||
|
|
||||||
|
class AuthRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
// POST /register
|
||||||
|
router.post('/register', _register);
|
||||||
|
|
||||||
|
// POST /login
|
||||||
|
router.post('/login', _login);
|
||||||
|
|
||||||
|
// POST /refresh
|
||||||
|
router.post('/refresh', _refresh);
|
||||||
|
|
||||||
|
// POST /logout
|
||||||
|
router.post('/logout', _logout);
|
||||||
|
|
||||||
|
// DELETE /account
|
||||||
|
router.delete('/account', _deleteAccount);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _register(Request request) async {
|
||||||
|
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
final email = body['email'] as String? ?? '';
|
||||||
|
final username = body['username'] as String? ?? '';
|
||||||
|
final password = body['password'] as String? ?? '';
|
||||||
|
|
||||||
|
final result = await AuthService.register(
|
||||||
|
email: email,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.created(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _login(Request request) async {
|
||||||
|
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
final email = body['email'] as String? ?? '';
|
||||||
|
final password = body['password'] as String? ?? '';
|
||||||
|
|
||||||
|
final result = await AuthService.login(
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _refresh(Request request) async {
|
||||||
|
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
final refreshToken = body['refresh_token'] as String? ?? '';
|
||||||
|
|
||||||
|
if (refreshToken.isEmpty) {
|
||||||
|
return ApiResponse.error('BAD_REQUEST', 'refresh_token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await AuthService.refresh(refreshToken: refreshToken);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _logout(Request request) async {
|
||||||
|
final body = jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
final refreshToken = body['refresh_token'] as String? ?? '';
|
||||||
|
|
||||||
|
if (refreshToken.isEmpty) {
|
||||||
|
return ApiResponse.error('BAD_REQUEST', 'refresh_token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthService.logout(refreshToken: refreshToken);
|
||||||
|
return ApiResponse.noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _deleteAccount(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) {
|
||||||
|
return ApiResponse.unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthService.deleteAccount(userId: userId);
|
||||||
|
return ApiResponse.noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
203
lib/src/modules/auth/auth_service.dart
Normal file
203
lib/src/modules/auth/auth_service.dart
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../../config/database.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
import 'password_hasher.dart';
|
||||||
|
import 'token_service.dart';
|
||||||
|
|
||||||
|
final _log = Logger('AuthService');
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
/// Register a new user account.
|
||||||
|
///
|
||||||
|
/// Returns a map with `user`, `access_token`, and `refresh_token`.
|
||||||
|
static Future<Map<String, dynamic>> register({
|
||||||
|
required String email,
|
||||||
|
required String username,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
// Validate input.
|
||||||
|
if (email.isEmpty || !email.contains('@')) {
|
||||||
|
throw ApiException.badRequest('Invalid email address');
|
||||||
|
}
|
||||||
|
if (username.isEmpty || username.length < 3) {
|
||||||
|
throw ApiException.badRequest('Username must be at least 3 characters');
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
throw ApiException.badRequest('Password must be at least 8 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing user.
|
||||||
|
final existing = await Database.query(
|
||||||
|
'SELECT id FROM users WHERE email = @email OR username = @username LIMIT 1',
|
||||||
|
parameters: {'email': email, 'username': username},
|
||||||
|
);
|
||||||
|
if (existing.isNotEmpty) {
|
||||||
|
throw ApiException.conflict('A user with that email or username already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
final userId = _uuid.v4();
|
||||||
|
final passwordHash = PasswordHasher.hash(password);
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'''INSERT INTO users (id, email, username, password_hash, created_at, updated_at)
|
||||||
|
VALUES (@id, @email, @username, @passwordHash, @createdAt, @updatedAt)''',
|
||||||
|
parameters: {
|
||||||
|
'id': userId,
|
||||||
|
'email': email,
|
||||||
|
'username': username,
|
||||||
|
'passwordHash': passwordHash,
|
||||||
|
'createdAt': now,
|
||||||
|
'updatedAt': now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate token pair.
|
||||||
|
final accessToken = TokenService.createAccessToken(userId);
|
||||||
|
final refreshToken = TokenService.generateRefreshToken();
|
||||||
|
await _storeRefreshToken(userId, refreshToken);
|
||||||
|
|
||||||
|
_log.info('User registered: $userId ($email)');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'user': {
|
||||||
|
'id': userId,
|
||||||
|
'email': email,
|
||||||
|
'username': username,
|
||||||
|
'created_at': now.toIso8601String(),
|
||||||
|
},
|
||||||
|
'access_token': accessToken,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate a user with email and password.
|
||||||
|
///
|
||||||
|
/// Returns a map with `user`, `access_token`, and `refresh_token`.
|
||||||
|
static Future<Map<String, dynamic>> login({
|
||||||
|
required String email,
|
||||||
|
required String password,
|
||||||
|
}) async {
|
||||||
|
final result = await Database.query(
|
||||||
|
'SELECT id, email, username, password_hash, created_at FROM users WHERE email = @email AND deleted_at IS NULL LIMIT 1',
|
||||||
|
parameters: {'email': email},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
throw ApiException.unauthorized('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
final row = result.first;
|
||||||
|
final storedHash = row[3] as String;
|
||||||
|
|
||||||
|
if (!PasswordHasher.verify(password, storedHash)) {
|
||||||
|
throw ApiException.unauthorized('Invalid email or password');
|
||||||
|
}
|
||||||
|
|
||||||
|
final userId = row[0] as String;
|
||||||
|
final accessToken = TokenService.createAccessToken(userId);
|
||||||
|
final refreshToken = TokenService.generateRefreshToken();
|
||||||
|
await _storeRefreshToken(userId, refreshToken);
|
||||||
|
|
||||||
|
_log.info('User logged in: $userId');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'user': {
|
||||||
|
'id': userId,
|
||||||
|
'email': row[1] as String,
|
||||||
|
'username': row[2] as String,
|
||||||
|
'created_at': (row[4] as DateTime).toIso8601String(),
|
||||||
|
},
|
||||||
|
'access_token': accessToken,
|
||||||
|
'refresh_token': refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotate a refresh token: validate the old one, revoke it, and issue a new
|
||||||
|
/// access + refresh pair.
|
||||||
|
static Future<Map<String, dynamic>> refresh({
|
||||||
|
required String refreshToken,
|
||||||
|
}) async {
|
||||||
|
final tokenHash = TokenService.hashRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
final result = await Database.query(
|
||||||
|
'''SELECT id, user_id FROM refresh_tokens
|
||||||
|
WHERE token_hash = @tokenHash AND revoked = false AND expires_at > NOW()
|
||||||
|
LIMIT 1''',
|
||||||
|
parameters: {'tokenHash': tokenHash},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
throw ApiException.unauthorized('Invalid or expired refresh token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final tokenId = result.first[0] as String;
|
||||||
|
final userId = result.first[1] as String;
|
||||||
|
|
||||||
|
// Revoke old token.
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE refresh_tokens SET revoked = true WHERE id = @id',
|
||||||
|
parameters: {'id': tokenId},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Issue new pair.
|
||||||
|
final newAccessToken = TokenService.createAccessToken(userId);
|
||||||
|
final newRefreshToken = TokenService.generateRefreshToken();
|
||||||
|
await _storeRefreshToken(userId, newRefreshToken);
|
||||||
|
|
||||||
|
_log.info('Tokens refreshed for user: $userId');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'access_token': newAccessToken,
|
||||||
|
'refresh_token': newRefreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke a refresh token (logout).
|
||||||
|
static Future<void> logout({required String refreshToken}) async {
|
||||||
|
final tokenHash = TokenService.hashRefreshToken(refreshToken);
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE refresh_tokens SET revoked = true WHERE token_hash = @tokenHash',
|
||||||
|
parameters: {'tokenHash': tokenHash},
|
||||||
|
);
|
||||||
|
_log.info('Refresh token revoked');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a user account.
|
||||||
|
static Future<void> deleteAccount({required String userId}) async {
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE users SET deleted_at = NOW() WHERE id = @id',
|
||||||
|
parameters: {'id': userId},
|
||||||
|
);
|
||||||
|
// Revoke all refresh tokens for the user.
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE refresh_tokens SET revoked = true WHERE user_id = @userId',
|
||||||
|
parameters: {'userId': userId},
|
||||||
|
);
|
||||||
|
_log.info('Account deleted (soft): $userId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
static Future<void> _storeRefreshToken(
|
||||||
|
String userId, String refreshToken) async {
|
||||||
|
final tokenHash = TokenService.hashRefreshToken(refreshToken);
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final expiresAt = DateTime.now().toUtc().add(const Duration(days: 30));
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'''INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked)
|
||||||
|
VALUES (@id, @userId, @tokenHash, @expiresAt, false)''',
|
||||||
|
parameters: {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'tokenHash': tokenHash,
|
||||||
|
'expiresAt': expiresAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/src/modules/auth/password_hasher.dart
Normal file
86
lib/src/modules/auth/password_hasher.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
/// PBKDF2-SHA256 password hasher.
|
||||||
|
///
|
||||||
|
/// Stores passwords as `<iterations>:<base64-salt>:<base64-hash>`.
|
||||||
|
class PasswordHasher {
|
||||||
|
static const int _iterations = 100000;
|
||||||
|
static const int _keyLength = 32; // 256 bits
|
||||||
|
static const int _saltLength = 32; // 256-bit salt
|
||||||
|
|
||||||
|
/// Hash a plaintext [password] and return the encoded string.
|
||||||
|
static String hash(String password) {
|
||||||
|
final salt = _generateSalt();
|
||||||
|
final hash = _pbkdf2(password, salt, _iterations, _keyLength);
|
||||||
|
final saltB64 = base64Encode(salt);
|
||||||
|
final hashB64 = base64Encode(hash);
|
||||||
|
return '$_iterations:$saltB64:$hashB64';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a plaintext [password] against a previously [encoded] hash.
|
||||||
|
static bool verify(String password, String encoded) {
|
||||||
|
final parts = encoded.split(':');
|
||||||
|
if (parts.length != 3) return false;
|
||||||
|
|
||||||
|
final iterations = int.tryParse(parts[0]);
|
||||||
|
if (iterations == null) return false;
|
||||||
|
|
||||||
|
final salt = base64Decode(parts[1]);
|
||||||
|
final storedHash = base64Decode(parts[2]);
|
||||||
|
|
||||||
|
final computedHash =
|
||||||
|
_pbkdf2(password, salt, iterations, storedHash.length);
|
||||||
|
|
||||||
|
// Constant-time comparison.
|
||||||
|
if (computedHash.length != storedHash.length) return false;
|
||||||
|
var result = 0;
|
||||||
|
for (var i = 0; i < computedHash.length; i++) {
|
||||||
|
result |= computedHash[i] ^ storedHash[i];
|
||||||
|
}
|
||||||
|
return result == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random salt.
|
||||||
|
static Uint8List _generateSalt() {
|
||||||
|
final random = Random.secure();
|
||||||
|
return Uint8List.fromList(
|
||||||
|
List<int>.generate(_saltLength, (_) => random.nextInt(256)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure-Dart PBKDF2 with HMAC-SHA256.
|
||||||
|
static Uint8List _pbkdf2(
|
||||||
|
String password, List<int> salt, int iterations, int keyLength) {
|
||||||
|
final passwordBytes = utf8.encode(password);
|
||||||
|
final hmacTemplate = Hmac(sha256, passwordBytes);
|
||||||
|
|
||||||
|
final blocks = (keyLength / 32).ceil();
|
||||||
|
final result = BytesBuilder();
|
||||||
|
|
||||||
|
for (var blockIndex = 1; blockIndex <= blocks; blockIndex++) {
|
||||||
|
// U_1 = HMAC(password, salt || INT_32_BE(blockIndex))
|
||||||
|
final saltWithIndex = Uint8List(salt.length + 4);
|
||||||
|
saltWithIndex.setRange(0, salt.length, salt);
|
||||||
|
saltWithIndex[salt.length] = (blockIndex >> 24) & 0xff;
|
||||||
|
saltWithIndex[salt.length + 1] = (blockIndex >> 16) & 0xff;
|
||||||
|
saltWithIndex[salt.length + 2] = (blockIndex >> 8) & 0xff;
|
||||||
|
saltWithIndex[salt.length + 3] = blockIndex & 0xff;
|
||||||
|
|
||||||
|
var u = hmacTemplate.convert(saltWithIndex).bytes;
|
||||||
|
var xorResult = Uint8List.fromList(u);
|
||||||
|
|
||||||
|
for (var i = 1; i < iterations; i++) {
|
||||||
|
u = hmacTemplate.convert(u).bytes;
|
||||||
|
for (var j = 0; j < xorResult.length; j++) {
|
||||||
|
xorResult[j] ^= u[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.add(xorResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uint8List.fromList(result.toBytes().sublist(0, keyLength));
|
||||||
|
}
|
||||||
|
}
|
||||||
55
lib/src/modules/auth/token_service.dart
Normal file
55
lib/src/modules/auth/token_service.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||||
|
import '../../config/env.dart';
|
||||||
|
|
||||||
|
class TokenService {
|
||||||
|
/// Create an access token (JWT) for the given [userId].
|
||||||
|
///
|
||||||
|
/// The token is signed with HS256, expires in 15 minutes, and contains
|
||||||
|
/// a `sub` (subject) claim set to the user's ID.
|
||||||
|
static String createAccessToken(String userId) {
|
||||||
|
final jwt = JWT(
|
||||||
|
{
|
||||||
|
'sub': userId,
|
||||||
|
'type': 'access',
|
||||||
|
},
|
||||||
|
issuer: 'cleanplate_api',
|
||||||
|
);
|
||||||
|
|
||||||
|
return jwt.sign(
|
||||||
|
SecretKey(Env.jwtSecret),
|
||||||
|
algorithm: JWTAlgorithm.HS256,
|
||||||
|
expiresIn: const Duration(minutes: 15),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an access token and return the userId (`sub` claim) if valid.
|
||||||
|
/// Returns `null` when the token is invalid or expired.
|
||||||
|
static String? verifyAccessToken(String token) {
|
||||||
|
try {
|
||||||
|
final jwt = JWT.verify(token, SecretKey(Env.jwtSecret));
|
||||||
|
final payload = jwt.payload as Map<String, dynamic>;
|
||||||
|
if (payload['type'] != 'access') return null;
|
||||||
|
return payload['sub'] as String?;
|
||||||
|
} on JWTExpiredException {
|
||||||
|
return null;
|
||||||
|
} on JWTException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a cryptographically random refresh token (64-byte hex string).
|
||||||
|
static String generateRefreshToken() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(64, (_) => random.nextInt(256));
|
||||||
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a refresh token with SHA-256 for safe storage in the database.
|
||||||
|
static String hashRefreshToken(String token) {
|
||||||
|
final bytes = utf8.encode(token);
|
||||||
|
return sha256.convert(bytes).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/src/modules/community/community_routes.dart
Normal file
71
lib/src/modules/community/community_routes.dart
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import 'review_service.dart';
|
||||||
|
|
||||||
|
class CommunityRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
router.get('/', _listReviews);
|
||||||
|
router.post('/', _createReview);
|
||||||
|
router.delete('/<reviewId>', _deleteReview);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _listReviews(Request request) async {
|
||||||
|
// The recipeId is passed via the request context (set by the parent mount).
|
||||||
|
final recipeId = request.context['recipeId'] as String?;
|
||||||
|
if (recipeId == null) {
|
||||||
|
return ApiResponse.error('BAD_REQUEST', 'Missing recipeId');
|
||||||
|
}
|
||||||
|
|
||||||
|
final limit =
|
||||||
|
int.tryParse(request.url.queryParameters['limit'] ?? '20') ?? 20;
|
||||||
|
final offset =
|
||||||
|
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
||||||
|
|
||||||
|
final result = await ReviewService.listReviews(
|
||||||
|
recipeId: recipeId,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(
|
||||||
|
result['reviews'],
|
||||||
|
meta: result['meta'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _createReview(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final recipeId = request.context['recipeId'] as String?;
|
||||||
|
if (recipeId == null) {
|
||||||
|
return ApiResponse.error('BAD_REQUEST', 'Missing recipeId');
|
||||||
|
}
|
||||||
|
|
||||||
|
final body =
|
||||||
|
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final review = await ReviewService.createReview(
|
||||||
|
recipeId: recipeId,
|
||||||
|
userId: userId,
|
||||||
|
rating: body['rating'] as int? ?? 0,
|
||||||
|
comment: body['comment'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.created(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _deleteReview(Request request, String reviewId) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
await ReviewService.deleteReview(reviewId: reviewId, userId: userId);
|
||||||
|
return ApiResponse.noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
lib/src/modules/community/review_service.dart
Normal file
119
lib/src/modules/community/review_service.dart
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../../config/database.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
|
||||||
|
final _log = Logger('ReviewService');
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Review / rating service for community features.
|
||||||
|
class ReviewService {
|
||||||
|
/// Create a review for a recipe.
|
||||||
|
static Future<Map<String, dynamic>> createReview({
|
||||||
|
required String recipeId,
|
||||||
|
required String userId,
|
||||||
|
required int rating,
|
||||||
|
String? comment,
|
||||||
|
}) async {
|
||||||
|
if (rating < 1 || rating > 5) {
|
||||||
|
throw ApiException.badRequest('Rating must be between 1 and 5');
|
||||||
|
}
|
||||||
|
|
||||||
|
final id = _uuid.v4();
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'''INSERT INTO reviews (id, recipe_id, user_id, rating, comment, created_at)
|
||||||
|
VALUES (@id, @recipeId, @userId, @rating, @comment, @createdAt)''',
|
||||||
|
parameters: {
|
||||||
|
'id': id,
|
||||||
|
'recipeId': recipeId,
|
||||||
|
'userId': userId,
|
||||||
|
'rating': rating,
|
||||||
|
'comment': comment,
|
||||||
|
'createdAt': now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info('Review created: $id for recipe $recipeId by user $userId');
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'recipe_id': recipeId,
|
||||||
|
'user_id': userId,
|
||||||
|
'rating': rating,
|
||||||
|
'comment': comment,
|
||||||
|
'created_at': now.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List reviews for a recipe.
|
||||||
|
static Future<Map<String, dynamic>> listReviews({
|
||||||
|
required String recipeId,
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
final countResult = await Database.query(
|
||||||
|
'SELECT COUNT(*) FROM reviews WHERE recipe_id = @recipeId',
|
||||||
|
parameters: {'recipeId': recipeId},
|
||||||
|
);
|
||||||
|
final totalCount = countResult.first[0] as int;
|
||||||
|
|
||||||
|
final rows = await Database.query(
|
||||||
|
'''SELECT id, user_id, rating, comment, created_at
|
||||||
|
FROM reviews
|
||||||
|
WHERE recipe_id = @recipeId
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT @limit OFFSET @offset''',
|
||||||
|
parameters: {
|
||||||
|
'recipeId': recipeId,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final reviews = rows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r[0],
|
||||||
|
'user_id': r[1],
|
||||||
|
'rating': r[2],
|
||||||
|
'comment': r[3],
|
||||||
|
'created_at': (r[4] as DateTime).toIso8601String(),
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'reviews': reviews,
|
||||||
|
'meta': {
|
||||||
|
'total_count': totalCount,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a review (only the author can delete).
|
||||||
|
static Future<void> deleteReview({
|
||||||
|
required String reviewId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
final result = await Database.query(
|
||||||
|
'SELECT user_id FROM reviews WHERE id = @id',
|
||||||
|
parameters: {'id': reviewId},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
throw ApiException.notFound('Review not found');
|
||||||
|
}
|
||||||
|
if (result.first[0] as String != userId) {
|
||||||
|
throw ApiException.forbidden('You can only delete your own reviews');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'DELETE FROM reviews WHERE id = @id',
|
||||||
|
parameters: {'id': reviewId},
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info('Review deleted: $reviewId');
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/src/modules/media/media_routes.dart
Normal file
25
lib/src/modules/media/media_routes.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
|
||||||
|
class MediaRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
router.post('/upload', _upload);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _upload(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
// TODO: Parse multipart form data, extract file, call MediaService.
|
||||||
|
return ApiResponse.error(
|
||||||
|
'NOT_IMPLEMENTED',
|
||||||
|
'Media upload is not yet implemented',
|
||||||
|
statusCode: 501,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/src/modules/media/media_service.dart
Normal file
25
lib/src/modules/media/media_service.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('MediaService');
|
||||||
|
|
||||||
|
/// Media upload and processing service.
|
||||||
|
///
|
||||||
|
/// TODO: Implement MinIO / S3-compatible object storage integration.
|
||||||
|
class MediaService {
|
||||||
|
/// Upload an image and return its public URL.
|
||||||
|
static Future<Map<String, dynamic>> uploadImage({
|
||||||
|
required String userId,
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
required String contentType,
|
||||||
|
}) async {
|
||||||
|
_log.info('Upload image: $filename (${bytes.length} bytes) by user $userId');
|
||||||
|
// Placeholder — return a fake URL.
|
||||||
|
return {
|
||||||
|
'url': 'https://storage.cleanplate.app/images/$filename',
|
||||||
|
'filename': filename,
|
||||||
|
'size': bytes.length,
|
||||||
|
'content_type': contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
349
lib/src/modules/recipes/recipe_repository.dart
Normal file
349
lib/src/modules/recipes/recipe_repository.dart
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import 'package:postgres/postgres.dart';
|
||||||
|
import '../../config/database.dart';
|
||||||
|
|
||||||
|
/// All PostgreSQL queries for the recipes module.
|
||||||
|
///
|
||||||
|
/// Every query uses parameterised placeholders to prevent SQL injection.
|
||||||
|
class RecipeRepository {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// CREATE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Insert a recipe header row and return the raw result.
|
||||||
|
static Future<void> insertRecipe(
|
||||||
|
TxSession tx, {
|
||||||
|
required String id,
|
||||||
|
required String userId,
|
||||||
|
required String title,
|
||||||
|
required String? description,
|
||||||
|
required int? prepTime,
|
||||||
|
required int? cookTime,
|
||||||
|
required int? servings,
|
||||||
|
required String? difficulty,
|
||||||
|
required String? cuisine,
|
||||||
|
required List<String> tags,
|
||||||
|
required String? imageUrl,
|
||||||
|
required bool isAiGenerated,
|
||||||
|
required DateTime createdAt,
|
||||||
|
}) async {
|
||||||
|
await tx.execute(
|
||||||
|
Sql.named('''
|
||||||
|
INSERT INTO recipes
|
||||||
|
(id, user_id, title, description, prep_time, cook_time, servings,
|
||||||
|
difficulty, cuisine, tags, image_url, is_ai_generated, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(@id, @userId, @title, @description, @prepTime, @cookTime, @servings,
|
||||||
|
@difficulty, @cuisine, @tags, @imageUrl, @isAiGenerated, @createdAt, @createdAt)
|
||||||
|
'''),
|
||||||
|
parameters: {
|
||||||
|
'id': id,
|
||||||
|
'userId': userId,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'prepTime': prepTime,
|
||||||
|
'cookTime': cookTime,
|
||||||
|
'servings': servings,
|
||||||
|
'difficulty': difficulty,
|
||||||
|
'cuisine': cuisine,
|
||||||
|
'tags': tags,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'isAiGenerated': isAiGenerated,
|
||||||
|
'createdAt': createdAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a single ingredient row.
|
||||||
|
static Future<void> insertIngredient(
|
||||||
|
TxSession tx, {
|
||||||
|
required String id,
|
||||||
|
required String recipeId,
|
||||||
|
required String name,
|
||||||
|
required String? quantity,
|
||||||
|
required String? unit,
|
||||||
|
required int sortOrder,
|
||||||
|
}) async {
|
||||||
|
await tx.execute(
|
||||||
|
Sql.named('''
|
||||||
|
INSERT INTO ingredients (id, recipe_id, name, quantity, unit, sort_order)
|
||||||
|
VALUES (@id, @recipeId, @name, @quantity, @unit, @sortOrder)
|
||||||
|
'''),
|
||||||
|
parameters: {
|
||||||
|
'id': id,
|
||||||
|
'recipeId': recipeId,
|
||||||
|
'name': name,
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit': unit,
|
||||||
|
'sortOrder': sortOrder,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a single step row.
|
||||||
|
static Future<void> insertStep(
|
||||||
|
TxSession tx, {
|
||||||
|
required String id,
|
||||||
|
required String recipeId,
|
||||||
|
required int stepNumber,
|
||||||
|
required String instruction,
|
||||||
|
required int? durationMinutes,
|
||||||
|
}) async {
|
||||||
|
await tx.execute(
|
||||||
|
Sql.named('''
|
||||||
|
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
|
||||||
|
VALUES (@id, @recipeId, @stepNumber, @instruction, @durationMinutes)
|
||||||
|
'''),
|
||||||
|
parameters: {
|
||||||
|
'id': id,
|
||||||
|
'recipeId': recipeId,
|
||||||
|
'stepNumber': stepNumber,
|
||||||
|
'instruction': instruction,
|
||||||
|
'durationMinutes': durationMinutes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// READ (list)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Count recipes matching optional filters.
|
||||||
|
static Future<int> countRecipes({
|
||||||
|
String? cuisine,
|
||||||
|
String? difficulty,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
final where = <String>['deleted_at IS NULL'];
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (cuisine != null) {
|
||||||
|
where.add('cuisine = @cuisine');
|
||||||
|
params['cuisine'] = cuisine;
|
||||||
|
}
|
||||||
|
if (difficulty != null) {
|
||||||
|
where.add('difficulty = @difficulty');
|
||||||
|
params['difficulty'] = difficulty;
|
||||||
|
}
|
||||||
|
if (userId != null) {
|
||||||
|
where.add('user_id = @userId');
|
||||||
|
params['userId'] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await Database.query(
|
||||||
|
'SELECT COUNT(*) FROM recipes WHERE ${where.join(' AND ')}',
|
||||||
|
parameters: params,
|
||||||
|
);
|
||||||
|
return result.first[0] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List recipes with pagination and optional filters.
|
||||||
|
static Future<Result> listRecipes({
|
||||||
|
required int limit,
|
||||||
|
required int offset,
|
||||||
|
String? cuisine,
|
||||||
|
String? difficulty,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
final where = <String>['deleted_at IS NULL'];
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cuisine != null) {
|
||||||
|
where.add('cuisine = @cuisine');
|
||||||
|
params['cuisine'] = cuisine;
|
||||||
|
}
|
||||||
|
if (difficulty != null) {
|
||||||
|
where.add('difficulty = @difficulty');
|
||||||
|
params['difficulty'] = difficulty;
|
||||||
|
}
|
||||||
|
if (userId != null) {
|
||||||
|
where.add('user_id = @userId');
|
||||||
|
params['userId'] = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Database.query(
|
||||||
|
'''SELECT id, user_id, title, description, prep_time, cook_time,
|
||||||
|
servings, difficulty, cuisine, tags, image_url, is_ai_generated,
|
||||||
|
created_at
|
||||||
|
FROM recipes
|
||||||
|
WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT @limit OFFSET @offset''',
|
||||||
|
parameters: params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// READ (single)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch a single recipe by id.
|
||||||
|
static Future<Result> getRecipeById(String recipeId) async {
|
||||||
|
return Database.query(
|
||||||
|
'''SELECT id, user_id, title, description, prep_time, cook_time,
|
||||||
|
servings, difficulty, cuisine, tags, image_url, is_ai_generated,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM recipes
|
||||||
|
WHERE id = @id AND deleted_at IS NULL''',
|
||||||
|
parameters: {'id': recipeId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch ingredients for a recipe.
|
||||||
|
static Future<Result> getIngredients(String recipeId) async {
|
||||||
|
return Database.query(
|
||||||
|
'''SELECT id, name, quantity, unit, sort_order
|
||||||
|
FROM ingredients
|
||||||
|
WHERE recipe_id = @recipeId
|
||||||
|
ORDER BY sort_order''',
|
||||||
|
parameters: {'recipeId': recipeId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch steps for a recipe.
|
||||||
|
static Future<Result> getSteps(String recipeId) async {
|
||||||
|
return Database.query(
|
||||||
|
'''SELECT id, step_number, instruction, duration_minutes
|
||||||
|
FROM steps
|
||||||
|
WHERE recipe_id = @recipeId
|
||||||
|
ORDER BY step_number''',
|
||||||
|
parameters: {'recipeId': recipeId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// UPDATE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Update a recipe's mutable fields.
|
||||||
|
static Future<void> updateRecipe({
|
||||||
|
required String recipeId,
|
||||||
|
required String userId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
int? prepTime,
|
||||||
|
int? cookTime,
|
||||||
|
int? servings,
|
||||||
|
String? difficulty,
|
||||||
|
String? cuisine,
|
||||||
|
List<String>? tags,
|
||||||
|
String? imageUrl,
|
||||||
|
}) async {
|
||||||
|
final sets = <String>['updated_at = NOW()'];
|
||||||
|
final params = <String, dynamic>{
|
||||||
|
'id': recipeId,
|
||||||
|
'userId': userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (title != null) {
|
||||||
|
sets.add('title = @title');
|
||||||
|
params['title'] = title;
|
||||||
|
}
|
||||||
|
if (description != null) {
|
||||||
|
sets.add('description = @description');
|
||||||
|
params['description'] = description;
|
||||||
|
}
|
||||||
|
if (prepTime != null) {
|
||||||
|
sets.add('prep_time = @prepTime');
|
||||||
|
params['prepTime'] = prepTime;
|
||||||
|
}
|
||||||
|
if (cookTime != null) {
|
||||||
|
sets.add('cook_time = @cookTime');
|
||||||
|
params['cookTime'] = cookTime;
|
||||||
|
}
|
||||||
|
if (servings != null) {
|
||||||
|
sets.add('servings = @servings');
|
||||||
|
params['servings'] = servings;
|
||||||
|
}
|
||||||
|
if (difficulty != null) {
|
||||||
|
sets.add('difficulty = @difficulty');
|
||||||
|
params['difficulty'] = difficulty;
|
||||||
|
}
|
||||||
|
if (cuisine != null) {
|
||||||
|
sets.add('cuisine = @cuisine');
|
||||||
|
params['cuisine'] = cuisine;
|
||||||
|
}
|
||||||
|
if (tags != null) {
|
||||||
|
sets.add('tags = @tags');
|
||||||
|
params['tags'] = tags;
|
||||||
|
}
|
||||||
|
if (imageUrl != null) {
|
||||||
|
sets.add('image_url = @imageUrl');
|
||||||
|
params['imageUrl'] = imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE recipes SET ${sets.join(', ')} WHERE id = @id AND user_id = @userId AND deleted_at IS NULL',
|
||||||
|
parameters: params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// DELETE (soft)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
static Future<Result> softDeleteRecipe(
|
||||||
|
{required String recipeId, required String userId}) async {
|
||||||
|
return Database.execute(
|
||||||
|
'UPDATE recipes SET deleted_at = NOW() WHERE id = @id AND user_id = @userId AND deleted_at IS NULL',
|
||||||
|
parameters: {'id': recipeId, 'userId': userId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SAVE / UNSAVE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
static Future<void> saveRecipe({
|
||||||
|
required String id,
|
||||||
|
required String userId,
|
||||||
|
required String recipeId,
|
||||||
|
}) async {
|
||||||
|
await Database.execute(
|
||||||
|
'''INSERT INTO saved_recipes (id, user_id, recipe_id, created_at)
|
||||||
|
VALUES (@id, @userId, @recipeId, NOW())
|
||||||
|
ON CONFLICT (user_id, recipe_id) DO NOTHING''',
|
||||||
|
parameters: {'id': id, 'userId': userId, 'recipeId': recipeId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> unsaveRecipe({
|
||||||
|
required String userId,
|
||||||
|
required String recipeId,
|
||||||
|
}) async {
|
||||||
|
await Database.execute(
|
||||||
|
'DELETE FROM saved_recipes WHERE user_id = @userId AND recipe_id = @recipeId',
|
||||||
|
parameters: {'userId': userId, 'recipeId': recipeId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count saved recipes for a user.
|
||||||
|
static Future<int> countSavedRecipes(String userId) async {
|
||||||
|
final result = await Database.query(
|
||||||
|
'SELECT COUNT(*) FROM saved_recipes WHERE user_id = @userId',
|
||||||
|
parameters: {'userId': userId},
|
||||||
|
);
|
||||||
|
return result.first[0] as int;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List saved recipe ids for a user (paginated).
|
||||||
|
static Future<Result> listSavedRecipes({
|
||||||
|
required String userId,
|
||||||
|
required int limit,
|
||||||
|
required int offset,
|
||||||
|
}) async {
|
||||||
|
return Database.query(
|
||||||
|
'''SELECT r.id, r.user_id, r.title, r.description, r.prep_time,
|
||||||
|
r.cook_time, r.servings, r.difficulty, r.cuisine, r.tags,
|
||||||
|
r.image_url, r.is_ai_generated, r.created_at
|
||||||
|
FROM saved_recipes sr
|
||||||
|
JOIN recipes r ON r.id = sr.recipe_id AND r.deleted_at IS NULL
|
||||||
|
WHERE sr.user_id = @userId
|
||||||
|
ORDER BY sr.created_at DESC
|
||||||
|
LIMIT @limit OFFSET @offset''',
|
||||||
|
parameters: {'userId': userId, 'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
lib/src/modules/recipes/recipe_routes.dart
Normal file
145
lib/src/modules/recipes/recipe_routes.dart
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import '../../shared/pagination.dart';
|
||||||
|
import 'recipe_service.dart';
|
||||||
|
|
||||||
|
class RecipeRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
// Public
|
||||||
|
router.get('/', _listRecipes);
|
||||||
|
router.get('/saved', _listSavedRecipes);
|
||||||
|
router.get('/<id>', _getRecipe);
|
||||||
|
|
||||||
|
// Authenticated
|
||||||
|
router.post('/', _createRecipe);
|
||||||
|
router.put('/<id>', _updateRecipe);
|
||||||
|
router.delete('/<id>', _deleteRecipe);
|
||||||
|
|
||||||
|
// Save / unsave
|
||||||
|
router.post('/<id>/save', _saveRecipe);
|
||||||
|
router.delete('/<id>/save', _unsaveRecipe);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<Response> _listRecipes(Request request) async {
|
||||||
|
final pagination = PaginationParams.fromRequest(request);
|
||||||
|
final params = request.url.queryParameters;
|
||||||
|
|
||||||
|
final result = await RecipeService.listRecipes(
|
||||||
|
pagination: pagination,
|
||||||
|
cuisine: params['cuisine'],
|
||||||
|
difficulty: params['difficulty'],
|
||||||
|
userId: params['user_id'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(
|
||||||
|
result['recipes'],
|
||||||
|
meta: result['meta'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _getRecipe(Request request, String id) async {
|
||||||
|
final recipe = await RecipeService.getRecipeById(id);
|
||||||
|
return ApiResponse.success(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _createRecipe(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final body =
|
||||||
|
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final recipe = await RecipeService.createRecipe(
|
||||||
|
userId: userId,
|
||||||
|
title: body['title'] as String? ?? '',
|
||||||
|
description: body['description'] as String?,
|
||||||
|
prepTime: body['prep_time'] as int?,
|
||||||
|
cookTime: body['cook_time'] as int?,
|
||||||
|
servings: body['servings'] as int?,
|
||||||
|
difficulty: body['difficulty'] as String?,
|
||||||
|
cuisine: body['cuisine'] as String?,
|
||||||
|
tags: (body['tags'] as List<dynamic>?)?.cast<String>(),
|
||||||
|
imageUrl: body['image_url'] as String?,
|
||||||
|
isAiGenerated: body['is_ai_generated'] as bool? ?? false,
|
||||||
|
ingredients:
|
||||||
|
(body['ingredients'] as List<dynamic>?)?.cast<Map<String, dynamic>>(),
|
||||||
|
steps: (body['steps'] as List<dynamic>?)?.cast<Map<String, dynamic>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.created(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _updateRecipe(Request request, String id) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final body =
|
||||||
|
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final recipe = await RecipeService.updateRecipe(
|
||||||
|
recipeId: id,
|
||||||
|
userId: userId,
|
||||||
|
title: body['title'] as String?,
|
||||||
|
description: body['description'] as String?,
|
||||||
|
prepTime: body['prep_time'] as int?,
|
||||||
|
cookTime: body['cook_time'] as int?,
|
||||||
|
servings: body['servings'] as int?,
|
||||||
|
difficulty: body['difficulty'] as String?,
|
||||||
|
cuisine: body['cuisine'] as String?,
|
||||||
|
tags: (body['tags'] as List<dynamic>?)?.cast<String>(),
|
||||||
|
imageUrl: body['image_url'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _deleteRecipe(Request request, String id) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
await RecipeService.deleteRecipe(recipeId: id, userId: userId);
|
||||||
|
return ApiResponse.noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _saveRecipe(Request request, String id) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
await RecipeService.saveRecipe(userId: userId, recipeId: id);
|
||||||
|
return ApiResponse.success({'saved': true}, statusCode: 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _unsaveRecipe(Request request, String id) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
await RecipeService.unsaveRecipe(userId: userId, recipeId: id);
|
||||||
|
return ApiResponse.noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _listSavedRecipes(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final pagination = PaginationParams.fromRequest(request);
|
||||||
|
final result = await RecipeService.listSavedRecipes(
|
||||||
|
userId: userId,
|
||||||
|
pagination: pagination,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(
|
||||||
|
result['recipes'],
|
||||||
|
meta: result['meta'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
317
lib/src/modules/recipes/recipe_service.dart
Normal file
317
lib/src/modules/recipes/recipe_service.dart
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../../config/database.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
import '../../shared/pagination.dart';
|
||||||
|
import 'recipe_repository.dart';
|
||||||
|
|
||||||
|
final _log = Logger('RecipeService');
|
||||||
|
const _uuid = Uuid();
|
||||||
|
|
||||||
|
class RecipeService {
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// CREATE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Create a recipe together with its ingredients and steps inside a single
|
||||||
|
/// database transaction.
|
||||||
|
static Future<Map<String, dynamic>> createRecipe({
|
||||||
|
required String userId,
|
||||||
|
required String title,
|
||||||
|
String? description,
|
||||||
|
int? prepTime,
|
||||||
|
int? cookTime,
|
||||||
|
int? servings,
|
||||||
|
String? difficulty,
|
||||||
|
String? cuisine,
|
||||||
|
List<String>? tags,
|
||||||
|
String? imageUrl,
|
||||||
|
bool isAiGenerated = false,
|
||||||
|
List<Map<String, dynamic>>? ingredients,
|
||||||
|
List<Map<String, dynamic>>? steps,
|
||||||
|
}) async {
|
||||||
|
if (title.isEmpty) {
|
||||||
|
throw ApiException.badRequest('Title is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
final recipeId = _uuid.v4();
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
|
||||||
|
await Database.transaction((tx) async {
|
||||||
|
await RecipeRepository.insertRecipe(
|
||||||
|
tx,
|
||||||
|
id: recipeId,
|
||||||
|
userId: userId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
prepTime: prepTime,
|
||||||
|
cookTime: cookTime,
|
||||||
|
servings: servings,
|
||||||
|
difficulty: difficulty,
|
||||||
|
cuisine: cuisine,
|
||||||
|
tags: tags ?? [],
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
isAiGenerated: isAiGenerated,
|
||||||
|
createdAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ingredients.
|
||||||
|
if (ingredients != null) {
|
||||||
|
for (var i = 0; i < ingredients.length; i++) {
|
||||||
|
final ing = ingredients[i];
|
||||||
|
await RecipeRepository.insertIngredient(
|
||||||
|
tx,
|
||||||
|
id: _uuid.v4(),
|
||||||
|
recipeId: recipeId,
|
||||||
|
name: ing['name'] as String? ?? '',
|
||||||
|
quantity: ing['quantity'] as String?,
|
||||||
|
unit: ing['unit'] as String?,
|
||||||
|
sortOrder: i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps.
|
||||||
|
if (steps != null) {
|
||||||
|
for (var i = 0; i < steps.length; i++) {
|
||||||
|
final step = steps[i];
|
||||||
|
await RecipeRepository.insertStep(
|
||||||
|
tx,
|
||||||
|
id: _uuid.v4(),
|
||||||
|
recipeId: recipeId,
|
||||||
|
stepNumber: i + 1,
|
||||||
|
instruction: step['instruction'] as String? ?? '',
|
||||||
|
durationMinutes: step['duration_minutes'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_log.info('Recipe created: $recipeId by user $userId');
|
||||||
|
return getRecipeById(recipeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// LIST
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// List recipes with pagination and optional filters.
|
||||||
|
static Future<Map<String, dynamic>> listRecipes({
|
||||||
|
required PaginationParams pagination,
|
||||||
|
String? cuisine,
|
||||||
|
String? difficulty,
|
||||||
|
String? userId,
|
||||||
|
}) async {
|
||||||
|
final totalCount = await RecipeRepository.countRecipes(
|
||||||
|
cuisine: cuisine,
|
||||||
|
difficulty: difficulty,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final rows = await RecipeRepository.listRecipes(
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
cuisine: cuisine,
|
||||||
|
difficulty: difficulty,
|
||||||
|
userId: userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final recipes = rows.map(_rowToRecipeSummary).toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'recipes': recipes,
|
||||||
|
'meta': pagination.toMeta(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// GET BY ID
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch a single recipe with its ingredients and steps.
|
||||||
|
static Future<Map<String, dynamic>> getRecipeById(String recipeId) async {
|
||||||
|
final recipeRows = await RecipeRepository.getRecipeById(recipeId);
|
||||||
|
if (recipeRows.isEmpty) {
|
||||||
|
throw ApiException.notFound('Recipe not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final row = recipeRows.first;
|
||||||
|
final ingredientRows = await RecipeRepository.getIngredients(recipeId);
|
||||||
|
final stepRows = await RecipeRepository.getSteps(recipeId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': row[0],
|
||||||
|
'user_id': row[1],
|
||||||
|
'title': row[2],
|
||||||
|
'description': row[3],
|
||||||
|
'prep_time': row[4],
|
||||||
|
'cook_time': row[5],
|
||||||
|
'servings': row[6],
|
||||||
|
'difficulty': row[7],
|
||||||
|
'cuisine': row[8],
|
||||||
|
'tags': row[9],
|
||||||
|
'image_url': row[10],
|
||||||
|
'is_ai_generated': row[11],
|
||||||
|
'created_at': (row[12] as DateTime).toIso8601String(),
|
||||||
|
'updated_at': (row[13] as DateTime).toIso8601String(),
|
||||||
|
'ingredients': ingredientRows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r[0],
|
||||||
|
'name': r[1],
|
||||||
|
'quantity': r[2],
|
||||||
|
'unit': r[3],
|
||||||
|
'sort_order': r[4],
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
'steps': stepRows
|
||||||
|
.map((r) => {
|
||||||
|
'id': r[0],
|
||||||
|
'step_number': r[1],
|
||||||
|
'instruction': r[2],
|
||||||
|
'duration_minutes': r[3],
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// UPDATE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Update a recipe's fields. Only the owner can update.
|
||||||
|
static Future<Map<String, dynamic>> updateRecipe({
|
||||||
|
required String recipeId,
|
||||||
|
required String userId,
|
||||||
|
String? title,
|
||||||
|
String? description,
|
||||||
|
int? prepTime,
|
||||||
|
int? cookTime,
|
||||||
|
int? servings,
|
||||||
|
String? difficulty,
|
||||||
|
String? cuisine,
|
||||||
|
List<String>? tags,
|
||||||
|
String? imageUrl,
|
||||||
|
}) async {
|
||||||
|
// Verify ownership.
|
||||||
|
final existing = await RecipeRepository.getRecipeById(recipeId);
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
throw ApiException.notFound('Recipe not found');
|
||||||
|
}
|
||||||
|
if (existing.first[1] as String != userId) {
|
||||||
|
throw ApiException.forbidden('You can only update your own recipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeRepository.updateRecipe(
|
||||||
|
recipeId: recipeId,
|
||||||
|
userId: userId,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
prepTime: prepTime,
|
||||||
|
cookTime: cookTime,
|
||||||
|
servings: servings,
|
||||||
|
difficulty: difficulty,
|
||||||
|
cuisine: cuisine,
|
||||||
|
tags: tags,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info('Recipe updated: $recipeId');
|
||||||
|
return getRecipeById(recipeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// DELETE (soft)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Soft-delete a recipe. Only the owner can delete.
|
||||||
|
static Future<void> deleteRecipe({
|
||||||
|
required String recipeId,
|
||||||
|
required String userId,
|
||||||
|
}) async {
|
||||||
|
final existing = await RecipeRepository.getRecipeById(recipeId);
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
throw ApiException.notFound('Recipe not found');
|
||||||
|
}
|
||||||
|
if (existing.first[1] as String != userId) {
|
||||||
|
throw ApiException.forbidden('You can only delete your own recipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeRepository.softDeleteRecipe(
|
||||||
|
recipeId: recipeId, userId: userId);
|
||||||
|
_log.info('Recipe soft-deleted: $recipeId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// SAVE / UNSAVE
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Save (bookmark) a recipe for the authenticated user.
|
||||||
|
static Future<void> saveRecipe({
|
||||||
|
required String userId,
|
||||||
|
required String recipeId,
|
||||||
|
}) async {
|
||||||
|
final existing = await RecipeRepository.getRecipeById(recipeId);
|
||||||
|
if (existing.isEmpty) {
|
||||||
|
throw ApiException.notFound('Recipe not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await RecipeRepository.saveRecipe(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
userId: userId,
|
||||||
|
recipeId: recipeId,
|
||||||
|
);
|
||||||
|
_log.info('Recipe saved: $recipeId by user $userId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a saved recipe bookmark.
|
||||||
|
static Future<void> unsaveRecipe({
|
||||||
|
required String userId,
|
||||||
|
required String recipeId,
|
||||||
|
}) async {
|
||||||
|
await RecipeRepository.unsaveRecipe(userId: userId, recipeId: recipeId);
|
||||||
|
_log.info('Recipe unsaved: $recipeId by user $userId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List saved recipes for the authenticated user.
|
||||||
|
static Future<Map<String, dynamic>> listSavedRecipes({
|
||||||
|
required String userId,
|
||||||
|
required PaginationParams pagination,
|
||||||
|
}) async {
|
||||||
|
final totalCount = await RecipeRepository.countSavedRecipes(userId);
|
||||||
|
|
||||||
|
final rows = await RecipeRepository.listSavedRecipes(
|
||||||
|
userId: userId,
|
||||||
|
limit: pagination.limit,
|
||||||
|
offset: pagination.offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
final recipes = rows.map(_rowToRecipeSummary).toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'recipes': recipes,
|
||||||
|
'meta': pagination.toMeta(totalCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
|
static Map<String, dynamic> _rowToRecipeSummary(dynamic row) {
|
||||||
|
return {
|
||||||
|
'id': row[0],
|
||||||
|
'user_id': row[1],
|
||||||
|
'title': row[2],
|
||||||
|
'description': row[3],
|
||||||
|
'prep_time': row[4],
|
||||||
|
'cook_time': row[5],
|
||||||
|
'servings': row[6],
|
||||||
|
'difficulty': row[7],
|
||||||
|
'cuisine': row[8],
|
||||||
|
'tags': row[9],
|
||||||
|
'image_url': row[10],
|
||||||
|
'is_ai_generated': row[11],
|
||||||
|
'created_at': (row[12] as DateTime).toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
lib/src/modules/search/search_routes.dart
Normal file
37
lib/src/modules/search/search_routes.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import 'search_service.dart';
|
||||||
|
|
||||||
|
class SearchRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
router.get('/', _search);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _search(Request request) async {
|
||||||
|
final query = request.url.queryParameters['q'] ?? '';
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return ApiResponse.error('BAD_REQUEST', 'Query parameter "q" is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
final limit =
|
||||||
|
int.tryParse(request.url.queryParameters['limit'] ?? '20') ?? 20;
|
||||||
|
final offset =
|
||||||
|
int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
||||||
|
|
||||||
|
final result = await SearchService.searchRecipes(
|
||||||
|
query: query,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(
|
||||||
|
result['recipes'],
|
||||||
|
meta: result['meta'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/src/modules/search/search_service.dart
Normal file
39
lib/src/modules/search/search_service.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
final _log = Logger('SearchService');
|
||||||
|
|
||||||
|
/// Meilisearch integration for full-text recipe search.
|
||||||
|
///
|
||||||
|
/// TODO: Implement Meilisearch HTTP client calls.
|
||||||
|
class SearchService {
|
||||||
|
static Future<Map<String, dynamic>> searchRecipes({
|
||||||
|
required String query,
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
Map<String, dynamic>? filters,
|
||||||
|
}) async {
|
||||||
|
_log.info('Search query: "$query" (limit=$limit, offset=$offset)');
|
||||||
|
// Placeholder — return empty results until Meilisearch is wired up.
|
||||||
|
return {
|
||||||
|
'recipes': <Map<String, dynamic>>[],
|
||||||
|
'meta': {
|
||||||
|
'query': query,
|
||||||
|
'total_count': 0,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index a recipe document in Meilisearch after creation / update.
|
||||||
|
static Future<void> indexRecipe(Map<String, dynamic> recipe) async {
|
||||||
|
_log.info('Indexing recipe: ${recipe['id']}');
|
||||||
|
// TODO: POST to Meilisearch /indexes/recipes/documents
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a recipe document from the index after deletion.
|
||||||
|
static Future<void> removeRecipe(String recipeId) async {
|
||||||
|
_log.info('Removing recipe from index: $recipeId');
|
||||||
|
// TODO: DELETE from Meilisearch index
|
||||||
|
}
|
||||||
|
}
|
||||||
47
lib/src/modules/users/user_routes.dart
Normal file
47
lib/src/modules/users/user_routes.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
import '../../shared/api_response.dart';
|
||||||
|
import 'user_service.dart';
|
||||||
|
|
||||||
|
class UserRoutes {
|
||||||
|
Router get router {
|
||||||
|
final router = Router();
|
||||||
|
|
||||||
|
router.get('/me', _getMe);
|
||||||
|
router.put('/me', _updateMe);
|
||||||
|
router.get('/<id>', _getProfile);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _getMe(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final profile = await UserService.getProfile(userId);
|
||||||
|
return ApiResponse.success(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _updateMe(Request request) async {
|
||||||
|
final userId = request.context['userId'] as String?;
|
||||||
|
if (userId == null) return ApiResponse.unauthorized();
|
||||||
|
|
||||||
|
final body =
|
||||||
|
jsonDecode(await request.readAsString()) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final profile = await UserService.updateProfile(
|
||||||
|
userId: userId,
|
||||||
|
username: body['username'] as String?,
|
||||||
|
bio: body['bio'] as String?,
|
||||||
|
avatarUrl: body['avatar_url'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ApiResponse.success(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _getProfile(Request request, String id) async {
|
||||||
|
final profile = await UserService.getProfile(id);
|
||||||
|
return ApiResponse.success(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
lib/src/modules/users/user_service.dart
Normal file
63
lib/src/modules/users/user_service.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import '../../config/database.dart';
|
||||||
|
import '../../middleware/error_handler.dart';
|
||||||
|
|
||||||
|
final _log = Logger('UserService');
|
||||||
|
|
||||||
|
/// User profile service.
|
||||||
|
class UserService {
|
||||||
|
/// Fetch the public profile of a user by ID.
|
||||||
|
static Future<Map<String, dynamic>> getProfile(String userId) async {
|
||||||
|
final result = await Database.query(
|
||||||
|
'''SELECT id, username, bio, avatar_url, created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = @id AND deleted_at IS NULL''',
|
||||||
|
parameters: {'id': userId},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
throw ApiException.notFound('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
final row = result.first;
|
||||||
|
return {
|
||||||
|
'id': row[0],
|
||||||
|
'username': row[1],
|
||||||
|
'bio': row[2],
|
||||||
|
'avatar_url': row[3],
|
||||||
|
'created_at': (row[4] as DateTime).toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the authenticated user's profile fields.
|
||||||
|
static Future<Map<String, dynamic>> updateProfile({
|
||||||
|
required String userId,
|
||||||
|
String? username,
|
||||||
|
String? bio,
|
||||||
|
String? avatarUrl,
|
||||||
|
}) async {
|
||||||
|
final sets = <String>['updated_at = NOW()'];
|
||||||
|
final params = <String, dynamic>{'id': userId};
|
||||||
|
|
||||||
|
if (username != null) {
|
||||||
|
sets.add('username = @username');
|
||||||
|
params['username'] = username;
|
||||||
|
}
|
||||||
|
if (bio != null) {
|
||||||
|
sets.add('bio = @bio');
|
||||||
|
params['bio'] = bio;
|
||||||
|
}
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
sets.add('avatar_url = @avatarUrl');
|
||||||
|
params['avatarUrl'] = avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.execute(
|
||||||
|
'UPDATE users SET ${sets.join(', ')} WHERE id = @id AND deleted_at IS NULL',
|
||||||
|
parameters: params,
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.info('Profile updated: $userId');
|
||||||
|
return getProfile(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
lib/src/shared/api_response.dart
Normal file
40
lib/src/shared/api_response.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
|
class ApiResponse {
|
||||||
|
static Response success(dynamic data,
|
||||||
|
{Map<String, dynamic>? meta, int statusCode = 200}) {
|
||||||
|
return Response(statusCode,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'success',
|
||||||
|
'data': data,
|
||||||
|
if (meta != null) 'meta': meta,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Response error(String code, String message,
|
||||||
|
{int statusCode = 400, dynamic details}) {
|
||||||
|
return Response(statusCode,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'status': 'error',
|
||||||
|
'error': {
|
||||||
|
'code': code,
|
||||||
|
'message': message,
|
||||||
|
if (details != null) 'details': details,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Response created(dynamic data) => success(data, statusCode: 201);
|
||||||
|
static Response noContent() => Response(204);
|
||||||
|
static Response notFound(String message) =>
|
||||||
|
error('NOT_FOUND', message, statusCode: 404);
|
||||||
|
static Response unauthorized([String message = 'Unauthorized']) =>
|
||||||
|
error('UNAUTHORIZED', message, statusCode: 401);
|
||||||
|
static Response forbidden([String message = 'Forbidden']) =>
|
||||||
|
error('FORBIDDEN', message, statusCode: 403);
|
||||||
|
static Response internalError([String message = 'Internal server error']) =>
|
||||||
|
error('INTERNAL_ERROR', message, statusCode: 500);
|
||||||
|
}
|
||||||
29
lib/src/shared/pagination.dart
Normal file
29
lib/src/shared/pagination.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
|
||||||
|
class PaginationParams {
|
||||||
|
final int page;
|
||||||
|
final int perPage;
|
||||||
|
|
||||||
|
PaginationParams({required this.page, required this.perPage});
|
||||||
|
|
||||||
|
int get offset => (page - 1) * perPage;
|
||||||
|
int get limit => perPage;
|
||||||
|
|
||||||
|
factory PaginationParams.fromRequest(Request request) {
|
||||||
|
final queryParams = request.url.queryParameters;
|
||||||
|
final page = max(1, int.tryParse(queryParams['page'] ?? '1') ?? 1);
|
||||||
|
final perPage = min(
|
||||||
|
100, max(1, int.tryParse(queryParams['per_page'] ?? '20') ?? 20));
|
||||||
|
return PaginationParams(page: page, perPage: perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMeta(int totalCount) {
|
||||||
|
return {
|
||||||
|
'page': page,
|
||||||
|
'per_page': perPage,
|
||||||
|
'total_count': totalCount,
|
||||||
|
'total_pages': (totalCount / perPage).ceil(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
380
migrations/001_initial_schema.sql
Normal file
380
migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
-- CleanPlate Initial Schema
|
||||||
|
-- Migration 001: Core tables for MVP
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- EXTENSIONS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- USERS & AUTH
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
avatar_url VARCHAR(500),
|
||||||
|
subscription_tier VARCHAR(20) DEFAULT 'free',
|
||||||
|
subscription_expires_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id) WHERE revoked_at IS NULL;
|
||||||
|
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE dietary_profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
diet_type VARCHAR(50),
|
||||||
|
allergies TEXT[] DEFAULT '{}',
|
||||||
|
intolerances TEXT[] DEFAULT '{}',
|
||||||
|
calorie_target INTEGER,
|
||||||
|
excluded_ingredients TEXT[] DEFAULT '{}',
|
||||||
|
preferences_json JSONB DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- HOUSEHOLDS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE households (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE household_members (
|
||||||
|
household_id UUID NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
role VARCHAR(20) DEFAULT 'member',
|
||||||
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (household_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CANONICAL INGREDIENTS (reference dictionary)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE canonical_ingredients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(200) UNIQUE NOT NULL,
|
||||||
|
category VARCHAR(50),
|
||||||
|
aliases TEXT[] DEFAULT '{}',
|
||||||
|
nutrition_per_100g JSONB,
|
||||||
|
common_units TEXT[] DEFAULT '{}'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_canonical_name ON canonical_ingredients(name);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RECIPES
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE recipes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
author_id UUID REFERENCES users(id),
|
||||||
|
title VARCHAR(300) NOT NULL,
|
||||||
|
slug VARCHAR(350) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cuisine VARCHAR(50),
|
||||||
|
difficulty VARCHAR(20),
|
||||||
|
prep_time_min INTEGER,
|
||||||
|
cook_time_min INTEGER,
|
||||||
|
total_time_min INTEGER GENERATED ALWAYS AS (
|
||||||
|
COALESCE(prep_time_min, 0) + COALESCE(cook_time_min, 0)
|
||||||
|
) STORED,
|
||||||
|
servings INTEGER DEFAULT 4,
|
||||||
|
source_type VARCHAR(20) DEFAULT 'user',
|
||||||
|
status VARCHAR(20) DEFAULT 'published',
|
||||||
|
cover_image_url VARCHAR(500),
|
||||||
|
video_url VARCHAR(500),
|
||||||
|
tags TEXT[] DEFAULT '{}',
|
||||||
|
diet_labels TEXT[] DEFAULT '{}',
|
||||||
|
rating_avg NUMERIC(3,2) DEFAULT 0,
|
||||||
|
rating_count INTEGER DEFAULT 0,
|
||||||
|
save_count INTEGER DEFAULT 0,
|
||||||
|
version INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
published_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_recipes_cuisine ON recipes(cuisine);
|
||||||
|
CREATE INDEX idx_recipes_difficulty ON recipes(difficulty);
|
||||||
|
CREATE INDEX idx_recipes_total_time ON recipes(total_time_min);
|
||||||
|
CREATE INDEX idx_recipes_rating ON recipes(rating_avg DESC);
|
||||||
|
CREATE INDEX idx_recipes_tags ON recipes USING GIN(tags);
|
||||||
|
CREATE INDEX idx_recipes_diet_labels ON recipes USING GIN(diet_labels);
|
||||||
|
CREATE INDEX idx_recipes_status ON recipes(status) WHERE status = 'published';
|
||||||
|
CREATE INDEX idx_recipes_author ON recipes(author_id);
|
||||||
|
|
||||||
|
-- Full-text search vector
|
||||||
|
ALTER TABLE recipes ADD COLUMN search_vector tsvector
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(description, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'C')
|
||||||
|
) STORED;
|
||||||
|
CREATE INDEX idx_recipes_search ON recipes USING GIN(search_vector);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- INGREDIENTS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE ingredients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(200) NOT NULL,
|
||||||
|
quantity NUMERIC(10,3),
|
||||||
|
unit VARCHAR(50),
|
||||||
|
group_name VARCHAR(100),
|
||||||
|
sort_order INTEGER NOT NULL,
|
||||||
|
optional BOOLEAN DEFAULT FALSE,
|
||||||
|
canonical_ingredient_id UUID REFERENCES canonical_ingredients(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id);
|
||||||
|
CREATE INDEX idx_ingredients_canonical ON ingredients(canonical_ingredient_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RECIPE STEPS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE recipe_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
step_number INTEGER NOT NULL,
|
||||||
|
instruction TEXT NOT NULL,
|
||||||
|
duration_min INTEGER,
|
||||||
|
image_url VARCHAR(500),
|
||||||
|
tip TEXT,
|
||||||
|
UNIQUE(recipe_id, step_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_steps_recipe ON recipe_steps(recipe_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- RECIPE NUTRITION
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE recipe_nutrition (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipe_id UUID UNIQUE NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
per_serving BOOLEAN DEFAULT TRUE,
|
||||||
|
calories NUMERIC(8,2),
|
||||||
|
protein_g NUMERIC(8,2),
|
||||||
|
fat_g NUMERIC(8,2),
|
||||||
|
saturated_fat_g NUMERIC(8,2),
|
||||||
|
carbs_g NUMERIC(8,2),
|
||||||
|
fiber_g NUMERIC(8,2),
|
||||||
|
sugar_g NUMERIC(8,2),
|
||||||
|
sodium_mg NUMERIC(8,2),
|
||||||
|
calculated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
source VARCHAR(20) DEFAULT 'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- COMMUNITY (Reviews)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
||||||
|
title VARCHAR(200),
|
||||||
|
body TEXT,
|
||||||
|
helpful_count INTEGER DEFAULT 0,
|
||||||
|
status VARCHAR(20) DEFAULT 'published',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(recipe_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reviews_recipe ON reviews(recipe_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_reviews_user ON reviews(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE review_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
review_id UUID NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||||
|
reporter_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
reason VARCHAR(50) NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SAVED RECIPES & COLLECTIONS
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE saved_recipes (
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
saved_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
synced_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (user_id, recipe_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE collections (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_public BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE collection_recipes (
|
||||||
|
collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
added_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
sort_order INTEGER,
|
||||||
|
PRIMARY KEY (collection_id, recipe_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- MEAL PLANNING
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE meal_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
household_id UUID REFERENCES households(id),
|
||||||
|
name VARCHAR(100),
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE meal_plan_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
||||||
|
recipe_id UUID NOT NULL REFERENCES recipes(id),
|
||||||
|
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6),
|
||||||
|
meal_type VARCHAR(20) NOT NULL,
|
||||||
|
servings INTEGER DEFAULT 1,
|
||||||
|
notes TEXT,
|
||||||
|
sort_order INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE grocery_lists (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
plan_id UUID NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE,
|
||||||
|
generated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE grocery_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
list_id UUID NOT NULL REFERENCES grocery_lists(id) ON DELETE CASCADE,
|
||||||
|
ingredient_name VARCHAR(200) NOT NULL,
|
||||||
|
quantity NUMERIC(10,3),
|
||||||
|
unit VARCHAR(50),
|
||||||
|
category VARCHAR(50),
|
||||||
|
checked BOOLEAN DEFAULT FALSE,
|
||||||
|
added_manually BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- MEDIA
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE media (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
uploader_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
type VARCHAR(20) NOT NULL,
|
||||||
|
original_url VARCHAR(500) NOT NULL,
|
||||||
|
processed_url VARCHAR(500),
|
||||||
|
thumbnail_url VARCHAR(500),
|
||||||
|
status VARCHAR(20) DEFAULT 'processing',
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
size_bytes BIGINT,
|
||||||
|
duration_sec INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- AI GENERATION LOG
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE ai_generations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
prompt_hash VARCHAR(64) NOT NULL,
|
||||||
|
input_json JSONB NOT NULL,
|
||||||
|
output_json JSONB,
|
||||||
|
model_used VARCHAR(100),
|
||||||
|
tokens_in INTEGER,
|
||||||
|
tokens_out INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
cached BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ai_gen_prompt_hash ON ai_generations(prompt_hash);
|
||||||
|
CREATE INDEX idx_ai_gen_user ON ai_generations(user_id, created_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SYNC TRACKING
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE sync_log (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
entity_id UUID NOT NULL,
|
||||||
|
action VARCHAR(20) NOT NULL,
|
||||||
|
changed_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
changed_by UUID REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sync_log_type_time ON sync_log(entity_type, changed_at);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- UPDATED_AT TRIGGER FUNCTION
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Apply trigger to tables with updated_at
|
||||||
|
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_recipes_updated_at BEFORE UPDATE ON recipes
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_reviews_updated_at BEFORE UPDATE ON reviews
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER update_meal_plans_updated_at BEFORE UPDATE ON meal_plans
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
701
pubspec.lock
Normal file
701
pubspec.lock
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
_fe_analyzer_shared:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: _fe_analyzer_shared
|
||||||
|
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "85.0.0"
|
||||||
|
adaptive_number:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: adaptive_number
|
||||||
|
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
analyzer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: analyzer
|
||||||
|
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.7.1"
|
||||||
|
args:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.0"
|
||||||
|
boolean_selector:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: boolean_selector
|
||||||
|
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
buffer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: buffer
|
||||||
|
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
build:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build
|
||||||
|
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
build_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_config
|
||||||
|
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
build_daemon:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_daemon
|
||||||
|
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.1"
|
||||||
|
build_resolvers:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_resolvers
|
||||||
|
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
build_runner:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: build_runner
|
||||||
|
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.4"
|
||||||
|
build_runner_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: build_runner_core
|
||||||
|
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.1.2"
|
||||||
|
built_collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_collection
|
||||||
|
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
built_value:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: built_value
|
||||||
|
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.12.4"
|
||||||
|
charcode:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: charcode
|
||||||
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
checked_yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: checked_yaml
|
||||||
|
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
|
cli_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cli_config
|
||||||
|
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.0"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
code_builder:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: code_builder
|
||||||
|
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.11.1"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
convert:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: convert
|
||||||
|
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.2"
|
||||||
|
coverage:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: coverage
|
||||||
|
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.15.0"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
dart_jsonwebtoken:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dart_jsonwebtoken
|
||||||
|
sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.17.0"
|
||||||
|
dart_style:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_style
|
||||||
|
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
dotenv:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: dotenv
|
||||||
|
sha256: "379e64b6fc82d3df29461d349a1796ecd2c436c480d4653f3af6872eccbc90e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.0"
|
||||||
|
ed25519_edwards:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ed25519_edwards
|
||||||
|
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.1"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
frontend_server_client:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: frontend_server_client
|
||||||
|
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
glob:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: glob
|
||||||
|
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
graphs:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: graphs
|
||||||
|
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_methods:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_methods
|
||||||
|
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
http_multi_server:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_multi_server
|
||||||
|
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.2"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
io:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: io
|
||||||
|
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.5"
|
||||||
|
js:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: js
|
||||||
|
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.2"
|
||||||
|
json_annotation:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: json_annotation
|
||||||
|
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.9.0"
|
||||||
|
json_serializable:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: json_serializable
|
||||||
|
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.9.5"
|
||||||
|
lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
|
logging:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: logging
|
||||||
|
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
matcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: matcher
|
||||||
|
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.12.17"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.1"
|
||||||
|
mime:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
node_preamble:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: node_preamble
|
||||||
|
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.2"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
pointycastle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pointycastle
|
||||||
|
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.9.1"
|
||||||
|
pool:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pool
|
||||||
|
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.2"
|
||||||
|
postgres:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: postgres
|
||||||
|
sha256: "013c6dc668eaab9771c4d3f5fc3e87ed4b3cd4ab3587ac6943cc1f38509ff723"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.5.7"
|
||||||
|
pub_semver:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pub_semver
|
||||||
|
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
pubspec_parse:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: pubspec_parse
|
||||||
|
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
redis:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: redis
|
||||||
|
sha256: "32e28eb1ba2e0fe2af50bbd06e675e4dfdce4f0ba95c5bc885c72383a1b0b47e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
|
sasl_scram:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: sasl_scram
|
||||||
|
sha256: "5c27fd6058d53075c032539ba3cc7fa95006bb1d51a0db63a81b05756c265a83"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.2"
|
||||||
|
saslprep:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: saslprep
|
||||||
|
sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.3"
|
||||||
|
shelf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_cors_headers:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_cors_headers
|
||||||
|
sha256: a127c80f99bbef3474293db67a7608e3a0f1f0fcdb171dad77fa9bd2cd123ae4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.5"
|
||||||
|
shelf_packages_handler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_packages_handler
|
||||||
|
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.2"
|
||||||
|
shelf_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_router
|
||||||
|
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
|
shelf_static:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_static
|
||||||
|
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.3"
|
||||||
|
shelf_web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shelf_web_socket
|
||||||
|
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.0"
|
||||||
|
source_gen:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_gen
|
||||||
|
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
source_helper:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_helper
|
||||||
|
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.7"
|
||||||
|
source_map_stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_map_stack_trace
|
||||||
|
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.2"
|
||||||
|
source_maps:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_maps
|
||||||
|
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.13"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
stack_trace:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stack_trace
|
||||||
|
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.12.1"
|
||||||
|
stream_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_channel
|
||||||
|
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.4"
|
||||||
|
stream_transform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: stream_transform
|
||||||
|
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.1"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: test
|
||||||
|
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.26.3"
|
||||||
|
test_api:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_api
|
||||||
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.7"
|
||||||
|
test_core:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: test_core
|
||||||
|
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.12"
|
||||||
|
timing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timing
|
||||||
|
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
unorm_dart:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: unorm_dart
|
||||||
|
sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
|
vm_service:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vm_service
|
||||||
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "15.0.2"
|
||||||
|
watcher:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: watcher
|
||||||
|
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
web_socket:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket
|
||||||
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.1"
|
||||||
|
web_socket_channel:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web_socket_channel
|
||||||
|
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.3"
|
||||||
|
webkit_inspection_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: webkit_inspection_protocol
|
||||||
|
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.1"
|
||||||
|
yaml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: yaml
|
||||||
|
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.3"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.7.0 <4.0.0"
|
||||||
30
pubspec.yaml
Normal file
30
pubspec.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: cleanplate_api
|
||||||
|
description: CleanPlate Recipe App Backend API
|
||||||
|
version: 0.1.0
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.7.0
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
shelf: ^1.4.0
|
||||||
|
shelf_router: ^1.1.0
|
||||||
|
shelf_cors_headers: ^0.1.0
|
||||||
|
shelf_static: ^1.1.0
|
||||||
|
postgres: ^3.4.0
|
||||||
|
redis: ^3.0.0
|
||||||
|
dart_jsonwebtoken: ^2.14.0
|
||||||
|
crypto: ^3.0.0
|
||||||
|
uuid: ^4.5.0
|
||||||
|
dotenv: ^4.2.0
|
||||||
|
args: ^2.0.0
|
||||||
|
http: ^1.0.0
|
||||||
|
mime: ^2.0.0
|
||||||
|
logging: ^1.2.0
|
||||||
|
json_annotation: ^4.9.0
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
build_runner: ^2.4.0
|
||||||
|
json_serializable: ^6.8.0
|
||||||
|
lints: ^5.0.0
|
||||||
|
test: ^1.24.0
|
||||||
39
test/server_test.dart
Normal file
39
test/server_test.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final port = '8080';
|
||||||
|
final host = 'http://0.0.0.0:$port';
|
||||||
|
late Process p;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
p = await Process.start(
|
||||||
|
'dart',
|
||||||
|
['run', 'bin/server.dart'],
|
||||||
|
environment: {'PORT': port},
|
||||||
|
);
|
||||||
|
// Wait for server to start and print to stdout.
|
||||||
|
await p.stdout.first;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => p.kill());
|
||||||
|
|
||||||
|
test('Root', () async {
|
||||||
|
final response = await get(Uri.parse('$host/'));
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.body, 'Hello, World!\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Echo', () async {
|
||||||
|
final response = await get(Uri.parse('$host/echo/hello'));
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(response.body, 'hello\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('404', () async {
|
||||||
|
final response = await get(Uri.parse('$host/foobar'));
|
||||||
|
expect(response.statusCode, 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user