Files
docker-compose-projects/server/lib/server.dart
2026-02-23 16:40:06 +03:00

1269 lines
36 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import 'package:postgres/postgres.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_cors_headers/shelf_cors_headers.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';
final _uuid = const Uuid();
Future<void> runServer() async {
final db = _LazyDbConnection(_openConnection);
final jwtSecret = Platform.environment['JWT_SECRET'] ?? 'dev-secret';
final port = int.tryParse(Platform.environment['PORT'] ?? '') ?? 8080;
final app = _CatalogApi(db: db, jwtSecret: jwtSecret);
final handler = Pipeline()
.addMiddleware(corsHeaders())
.addHandler(app.router.call);
final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
print('Server started: http://${server.address.host}:${server.port}');
}
Future<PostgreSQLConnection> _openConnection() async {
final host = Platform.environment['PGHOST'] ?? '127.0.0.1';
final port = int.tryParse(Platform.environment['PGPORT'] ?? '') ?? 5432;
final database = Platform.environment['PGDATABASE'] ?? 'building_catalog';
final user = Platform.environment['PGUSER'] ?? 'postgres';
final password = Platform.environment['PGPASSWORD'] ?? 'postgres';
final maxAttempts =
int.tryParse(Platform.environment['DB_CONNECT_ATTEMPTS'] ?? '') ?? 12;
final retryDelayMs =
int.tryParse(Platform.environment['DB_CONNECT_DELAY_MS'] ?? '') ?? 1000;
Object? lastError;
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
final connection = PostgreSQLConnection(
host,
port,
database,
username: user,
password: password,
useSSL: false,
timeoutInSeconds: 10,
);
try {
await connection.open();
return connection;
} catch (e) {
lastError = e;
stderr.writeln(
'Database connection attempt $attempt/$maxAttempts failed: $e',
);
try {
await connection.close();
} catch (_) {}
if (attempt < maxAttempts) {
await Future.delayed(Duration(milliseconds: retryDelayMs));
}
}
}
throw Exception(
'Could not connect to Postgres at $host:$port/$database after $maxAttempts attempts. Last error: $lastError',
);
}
class _LazyDbConnection {
_LazyDbConnection(this._connector);
final Future<PostgreSQLConnection> Function() _connector;
PostgreSQLConnection? _connection;
Future<PostgreSQLConnection>? _pendingConnection;
Future<PostgreSQLConnection> _getConnection() async {
final activeConnection = _connection;
if (activeConnection != null) {
return activeConnection;
}
final pendingConnection = _pendingConnection;
if (pendingConnection != null) {
return pendingConnection;
}
final connectFuture = _connector();
_pendingConnection = connectFuture;
try {
final connection = await connectFuture;
_connection = connection;
return connection;
} finally {
_pendingConnection = null;
}
}
Future<List<List<dynamic>>> query(
String query, {
Map<String, dynamic>? substitutionValues,
}) async {
final connection = await _getConnection();
return connection.query(query, substitutionValues: substitutionValues);
}
Future<T> transaction<T>(
Future<T> Function(PostgreSQLExecutionContext ctx) callback,
) async {
final connection = await _getConnection();
final result = await connection.transaction(callback);
return result as T;
}
}
class _CatalogApi {
_CatalogApi({required _LazyDbConnection db, required String jwtSecret})
: _db = db,
_jwtSecret = jwtSecret {
_registerRoutes();
}
final _LazyDbConnection _db;
final String _jwtSecret;
final Router router = Router();
void _registerRoutes() {
router
..get('/health', (Request _) => _json({'ok': true}))
..get('/api/v1/categories', _getCategories)
..get('/api/v1/categories/<id>', _getCategory)
..get('/api/v1/brands', _getBrands)
..get('/api/v1/products', _getProducts)
..get('/api/v1/products/facets', _getProductFacets)
..get('/api/v1/products/<id>', _getProductById)
..post('/api/v1/auth/login', _login)
..post('/api/v1/admin/categories', _adminCreateCategory)
..patch('/api/v1/admin/categories/<id>', _adminPatchCategory)
..delete('/api/v1/admin/categories/<id>', _adminDeleteCategory)
..post('/api/v1/admin/brands', _adminCreateBrand)
..patch('/api/v1/admin/brands/<id>', _adminPatchBrand)
..delete('/api/v1/admin/brands/<id>', _adminDeleteBrand)
..post('/api/v1/admin/products', _adminCreateProduct)
..patch('/api/v1/admin/products/<id>', _adminPatchProduct)
..delete('/api/v1/admin/products/<id>', _adminDeleteProduct)
..patch('/api/v1/admin/products/<id>/stock', _adminPatchStock)
..patch('/api/v1/admin/products/<id>/price', _adminPatchPrice);
}
Future<Response> _getCategories(Request request) async {
try {
final tree = _boolArg(request, 'tree', true);
final activeOnly = _boolArg(request, 'activeOnly', true);
final whereSql = activeOnly ? 'WHERE c.is_active = TRUE' : '';
final rows = await _db.query('''
SELECT c.id, c.parent_id, c.name, c.slug, c.is_active
FROM categories c
$whereSql
ORDER BY c.name
''');
final items = rows
.map(
(row) => {
'id': row[0].toString(),
'parentId': row[1]?.toString(),
'name': row[2] as String,
'slug': row[3] as String,
'isActive': row[4] as bool,
},
)
.toList();
if (!tree) {
return _json(items);
}
final byParent = <String?, List<Map<String, dynamic>>>{};
for (final item in items) {
final parentId = item['parentId'] as String?;
byParent.putIfAbsent(parentId, () => []).add(item);
}
List<Map<String, dynamic>> buildTree(String? parentId) {
final nodes = byParent[parentId] ?? [];
return nodes
.map(
(node) => {
'id': node['id'],
'name': node['name'],
'slug': node['slug'],
'children': buildTree(node['id'] as String),
},
)
.toList();
}
return _json(buildTree(null));
} catch (e) {
return _serverError(e);
}
}
Future<Response> _getCategory(Request request, String id) async {
try {
final row = await _db.query(
'''
SELECT c.id
FROM categories c
WHERE c.id = @id
LIMIT 1
''',
substitutionValues: {'id': id},
);
if (row.isEmpty) {
return _error('not_found', 'Category not found', status: 404);
}
final categories = await _db.query(
'''
WITH RECURSIVE category_tree AS (
SELECT c.id, c.parent_id, c.name, c.slug, c.is_active
FROM categories c
WHERE c.id = @id
UNION ALL
SELECT child.id, child.parent_id, child.name, child.slug, child.is_active
FROM categories child
INNER JOIN category_tree ct ON child.parent_id = ct.id
)
SELECT id, parent_id, name, slug, is_active
FROM category_tree
ORDER BY name
''',
substitutionValues: {'id': id},
);
final items = categories
.map(
(entry) => {
'id': entry[0].toString(),
'parentId': entry[1]?.toString(),
'name': entry[2],
'slug': entry[3],
},
)
.toList();
final byParent = <String?, List<Map<String, dynamic>>>{};
for (final item in items) {
final parentId = item['parentId'] as String?;
byParent.putIfAbsent(parentId, () => []).add(item);
}
Map<String, dynamic> build(String nodeId) {
final node = items.firstWhere((entry) => entry['id'] == nodeId);
return {
'id': node['id'],
'name': node['name'],
'slug': node['slug'],
'children': (byParent[nodeId] ?? [])
.map((child) => build(child['id'] as String))
.toList(),
};
}
return _json(build(id));
} catch (e) {
return _serverError(e);
}
}
Future<Response> _getBrands(Request request) async {
try {
final q = request.url.queryParameters['q']?.trim();
final hasQuery = q != null && q.isNotEmpty;
final whereSql = hasQuery ? 'WHERE b.name ILIKE @like_q' : '';
final rows = await _db.query('''
SELECT b.id, b.name, b.slug
FROM brands b
$whereSql
ORDER BY b.name
''', substitutionValues: hasQuery ? {'like_q': '%$q%'} : {});
return _json(
rows
.map(
(row) => {
'id': row[0].toString(),
'name': row[1],
'slug': row[2],
},
)
.toList(),
);
} catch (e) {
return _serverError(e);
}
}
Future<Response> _getProducts(Request request) async {
try {
final page = _intArg(request, 'page', 1, min: 1);
final pageSize = _intArg(request, 'pageSize', 20, min: 1, max: 100);
final whereParts = <String>['p.is_active = TRUE'];
final params = <String, dynamic>{};
final q = request.url.queryParameters['q']?.trim();
if (q != null && q.isNotEmpty) {
whereParts.add(
'(p.name ILIKE @like_q OR p.description ILIKE @like_q OR p.sku ILIKE @like_q)',
);
params['like_q'] = '%$q%';
}
final categoryId = request.url.queryParameters['categoryId'];
if (categoryId != null && categoryId.isNotEmpty) {
whereParts.add('p.category_id = @category_id');
params['category_id'] = categoryId;
}
final brandId = request.url.queryParameters['brandId'];
if (brandId != null && brandId.isNotEmpty) {
whereParts.add('p.brand_id = @brand_id');
params['brand_id'] = brandId;
}
final priceMin = double.tryParse(
request.url.queryParameters['priceMin'] ?? '',
);
if (priceMin != null) {
whereParts.add('pr.price >= @price_min');
params['price_min'] = priceMin;
}
final priceMax = double.tryParse(
request.url.queryParameters['priceMax'] ?? '',
);
if (priceMax != null) {
whereParts.add('pr.price <= @price_max');
params['price_max'] = priceMax;
}
final inStock = _nullableBoolArg(request, 'inStock');
if (inStock != null) {
whereParts.add(
inStock
? '(st.qty - st.reserved_qty) > 0'
: '(st.qty - st.reserved_qty) <= 0',
);
}
final attrs = _parseAttrs(request.url.queryParameters['attrs']);
for (var i = 0; i < attrs.length; i++) {
final attr = attrs[i];
whereParts.add('''
EXISTS (
SELECT 1 FROM product_attributes pa$i
WHERE pa$i.product_id = p.id
AND pa$i.key = @attr_key_$i
AND pa$i.value = @attr_value_$i
)
''');
params['attr_key_$i'] = attr.$1;
params['attr_value_$i'] = attr.$2;
}
final sortRaw = request.url.queryParameters['sort'] ?? 'createdAt';
final orderRaw = request.url.queryParameters['order'] ?? 'desc';
final sort = switch (sortRaw) {
'price' => 'pr.price',
'name' => 'p.name',
_ => 'p.created_at',
};
final order = orderRaw.toLowerCase() == 'asc' ? 'ASC' : 'DESC';
final whereSql = whereParts.isEmpty
? ''
: 'WHERE ${whereParts.join(' AND ')}';
final countRows = await _db.query('''
SELECT COUNT(*)
FROM products p
LEFT JOIN prices pr ON pr.product_id = p.id
LEFT JOIN stocks st ON st.product_id = p.id
$whereSql
''', substitutionValues: params);
final total = _toInt(countRows.first.first);
final totalPages = total == 0 ? 0 : (total / pageSize).ceil();
params['limit'] = pageSize;
params['offset'] = (page - 1) * pageSize;
final rows = await _db.query('''
SELECT
p.id,
p.sku,
p.name,
p.unit,
p.is_active,
c.id,
c.name,
b.id,
b.name,
pr.currency,
pr.price,
pr.old_price,
st.qty,
st.reserved_qty,
(
SELECT pi.url
FROM product_images pi
WHERE pi.product_id = p.id
ORDER BY pi.sort_order
LIMIT 1
)
FROM products p
INNER JOIN categories c ON c.id = p.category_id
LEFT JOIN brands b ON b.id = p.brand_id
LEFT JOIN prices pr ON pr.product_id = p.id
LEFT JOIN stocks st ON st.product_id = p.id
$whereSql
ORDER BY $sort $order
LIMIT @limit OFFSET @offset
''', substitutionValues: params);
final items = rows
.map(
(row) => {
'id': row[0].toString(),
'sku': row[1],
'name': row[2],
'category': {'id': row[5].toString(), 'name': row[6]},
'brand': row[7] == null
? null
: {'id': row[7].toString(), 'name': row[8]},
'price': {
'currency': row[9] ?? 'RUB',
'price': _toDouble(row[10]),
'oldPrice': _nullableDouble(row[11]),
},
'unit': row[3],
'stock': {
'qty': _toDouble(row[12]),
'isInStock': _toDouble(row[12]) - _toDouble(row[13]) > 0,
},
'image': row[14],
'isActive': row[4] as bool,
},
)
.toList();
return _json({
'items': items,
'page': page,
'pageSize': pageSize,
'total': total,
'totalPages': totalPages,
});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _getProductById(Request request, String id) async {
try {
final rows = await _db.query(
'''
SELECT
p.id,
p.sku,
p.name,
p.description,
p.unit,
p.is_active,
p.created_at,
p.updated_at,
c.id,
c.name,
c.slug,
b.id,
b.name,
b.slug,
pr.currency,
pr.price,
pr.old_price,
st.qty,
st.reserved_qty
FROM products p
INNER JOIN categories c ON c.id = p.category_id
LEFT JOIN brands b ON b.id = p.brand_id
LEFT JOIN prices pr ON pr.product_id = p.id
LEFT JOIN stocks st ON st.product_id = p.id
WHERE p.id = @id AND p.is_active = TRUE
LIMIT 1
''',
substitutionValues: {'id': id},
);
if (rows.isEmpty) {
return _error('not_found', 'Product not found', status: 404);
}
final row = rows.first;
final imageRows = await _db.query(
'''
SELECT url, sort_order
FROM product_images
WHERE product_id = @id
ORDER BY sort_order
''',
substitutionValues: {'id': id},
);
final attrRows = await _db.query(
'''
SELECT key, value, unit
FROM product_attributes
WHERE product_id = @id
ORDER BY key
''',
substitutionValues: {'id': id},
);
return _json({
'id': row[0].toString(),
'sku': row[1],
'name': row[2],
'description': row[3],
'category': {'id': row[8].toString(), 'name': row[9], 'slug': row[10]},
'brand': row[11] == null
? null
: {'id': row[11].toString(), 'name': row[12], 'slug': row[13]},
'price': {
'currency': row[14] ?? 'RUB',
'price': _toDouble(row[15]),
'oldPrice': _nullableDouble(row[16]),
},
'unit': row[4],
'stock': {
'qty': _toDouble(row[17]),
'reservedQty': _toDouble(row[18]),
'isInStock': _toDouble(row[17]) - _toDouble(row[18]) > 0,
},
'images': imageRows
.map((img) => {'url': img[0], 'sortOrder': _toInt(img[1])})
.toList(),
'attributes': attrRows
.map((attr) => {'key': attr[0], 'value': attr[1], 'unit': attr[2]})
.toList(),
'isActive': row[5] as bool,
'createdAt': (row[6] as DateTime).toUtc().toIso8601String(),
'updatedAt': (row[7] as DateTime).toUtc().toIso8601String(),
});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _getProductFacets(Request request) async {
try {
final base = await _getProducts(request);
if (base.statusCode != 200) {
return base;
}
final payload =
jsonDecode(await base.readAsString()) as Map<String, dynamic>;
final items = (payload['items'] as List).cast<Map<String, dynamic>>();
if (items.isEmpty) {
return _json({
'price': {'min': 0, 'max': 0},
'brands': [],
'attrs': {},
});
}
final productIds = items
.map((e) => "'${e['id'].toString().replaceAll("'", "''")}'")
.join(',');
final priceRows = await _db.query('''
SELECT MIN(price), MAX(price)
FROM prices
WHERE product_id IN ($productIds)
''');
final brandRows = await _db.query('''
SELECT b.id, b.name, COUNT(*)
FROM products p
INNER JOIN brands b ON b.id = p.brand_id
WHERE p.id IN ($productIds)
GROUP BY b.id, b.name
ORDER BY b.name
''');
final attrRows = await _db.query('''
SELECT key, value, COUNT(*)
FROM product_attributes
WHERE product_id IN ($productIds)
GROUP BY key, value
ORDER BY key, value
''');
final attrs = <String, List<Map<String, dynamic>>>{};
for (final row in attrRows) {
final key = row[0] as String;
attrs.putIfAbsent(key, () => []).add({
'value': row[1],
'count': _toInt(row[2]),
});
}
return _json({
'price': {
'min': _toDouble(priceRows.first[0]),
'max': _toDouble(priceRows.first[1]),
},
'brands': brandRows
.map(
(row) => {
'id': row[0].toString(),
'name': row[1],
'count': _toInt(row[2]),
},
)
.toList(),
'attrs': attrs,
});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _login(Request request) async {
try {
final body = await _decodeBody(request);
final email = body['email']?.toString();
final password = body['password']?.toString();
final adminEmail =
Platform.environment['ADMIN_EMAIL'] ?? 'admin@shop.local';
final adminPassword = Platform.environment['ADMIN_PASSWORD'] ?? 'secret';
if (email != adminEmail || password != adminPassword) {
return _error('unauthorized', 'Invalid credentials', status: 401);
}
final jwt = JWT({'sub': email, 'role': 'admin'});
final accessToken = jwt.sign(
SecretKey(_jwtSecret),
expiresIn: const Duration(hours: 1),
);
return _json({'accessToken': accessToken, 'expiresIn': 3600});
} catch (e) {
return _error('validation_error', 'Invalid request body', status: 400);
}
}
Future<bool> _isAdmin(Request request) async {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return false;
}
final token = authHeader.substring(7);
try {
final jwt = JWT.verify(token, SecretKey(_jwtSecret));
return jwt.payload['role'] == 'admin';
} on JWTException {
return false;
}
}
Future<Response?> _requireAdmin(Request request) async {
if (await _isAdmin(request)) {
return null;
}
return _error('unauthorized', 'Admin token required', status: 401);
}
Future<Response> _adminCreateCategory(Request request) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
final id = _uuid.v4();
await _db.query(
'''
INSERT INTO categories (id, parent_id, name, slug, is_active)
VALUES (@id, @parent_id, @name, @slug, @is_active)
''',
substitutionValues: {
'id': id,
'parent_id': body['parentId'],
'name': body['name'],
'slug': body['slug'],
'is_active': body['isActive'] ?? true,
},
);
return _json({'id': id}, status: 201);
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminPatchCategory(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
await _db.query(
'''
UPDATE categories
SET
parent_id = COALESCE(@parent_id, parent_id),
name = COALESCE(@name, name),
slug = COALESCE(@slug, slug),
is_active = COALESCE(@is_active, is_active)
WHERE id = @id
''',
substitutionValues: {
'id': id,
'parent_id': body['parentId'],
'name': body['name'],
'slug': body['slug'],
'is_active': body['isActive'],
},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminDeleteCategory(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
await _db.query(
'DELETE FROM categories WHERE id = @id',
substitutionValues: {'id': id},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminCreateBrand(Request request) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
final id = _uuid.v4();
await _db.query(
'INSERT INTO brands (id, name, slug) VALUES (@id, @name, @slug)',
substitutionValues: {
'id': id,
'name': body['name'],
'slug': body['slug'],
},
);
return _json({'id': id}, status: 201);
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminPatchBrand(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
await _db.query(
'''
UPDATE brands
SET
name = COALESCE(@name, name),
slug = COALESCE(@slug, slug)
WHERE id = @id
''',
substitutionValues: {
'id': id,
'name': body['name'],
'slug': body['slug'],
},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminDeleteBrand(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
await _db.query(
'DELETE FROM brands WHERE id = @id',
substitutionValues: {'id': id},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminCreateProduct(Request request) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
final id = _uuid.v4();
await _db.transaction((ctx) async {
await ctx.query(
'''
INSERT INTO products (
id, sku, name, description, category_id, brand_id, unit, is_active
) VALUES (
@id, @sku, @name, @description, @category_id, @brand_id, @unit, @is_active
)
''',
substitutionValues: {
'id': id,
'sku': body['sku'],
'name': body['name'],
'description': body['description'],
'category_id': body['categoryId'],
'brand_id': body['brandId'],
'unit': body['unit'],
'is_active': body['isActive'] ?? true,
},
);
final price = body['price'] as Map<String, dynamic>?;
if (price != null) {
await ctx.query(
'''
INSERT INTO prices (product_id, currency, price, old_price)
VALUES (@product_id, @currency, @price, @old_price)
''',
substitutionValues: {
'product_id': id,
'currency': price['currency'] ?? 'RUB',
'price': price['price'] ?? 0,
'old_price': price['oldPrice'],
},
);
}
final stock = body['stock'] as Map<String, dynamic>?;
await ctx.query(
'''
INSERT INTO stocks (product_id, qty, reserved_qty)
VALUES (@product_id, @qty, @reserved_qty)
''',
substitutionValues: {
'product_id': id,
'qty': stock?['qty'] ?? 0,
'reserved_qty': stock?['reservedQty'] ?? 0,
},
);
final attributes = (body['attributes'] as List<dynamic>? ?? <dynamic>[])
.whereType<Map<String, dynamic>>()
.toList();
for (final attr in attributes) {
await ctx.query(
'''
INSERT INTO product_attributes (id, product_id, key, value, unit)
VALUES (@id, @product_id, @key, @value, @unit)
''',
substitutionValues: {
'id': _uuid.v4(),
'product_id': id,
'key': attr['key'],
'value': attr['value'],
'unit': attr['unit'],
},
);
}
final images = (body['images'] as List<dynamic>? ?? <dynamic>[])
.whereType<Map<String, dynamic>>()
.toList();
for (final image in images) {
await ctx.query(
'''
INSERT INTO product_images (id, product_id, url, sort_order)
VALUES (@id, @product_id, @url, @sort_order)
''',
substitutionValues: {
'id': _uuid.v4(),
'product_id': id,
'url': image['url'],
'sort_order': image['sortOrder'] ?? 1,
},
);
}
});
return _json({'id': id}, status: 201);
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminPatchProduct(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
await _db.transaction((ctx) async {
await ctx.query(
'''
UPDATE products
SET
sku = COALESCE(@sku, sku),
name = COALESCE(@name, name),
description = COALESCE(@description, description),
category_id = COALESCE(@category_id, category_id),
brand_id = COALESCE(@brand_id, brand_id),
unit = COALESCE(@unit, unit),
is_active = COALESCE(@is_active, is_active),
updated_at = NOW()
WHERE id = @id
''',
substitutionValues: {
'id': id,
'sku': body['sku'],
'name': body['name'],
'description': body['description'],
'category_id': body['categoryId'],
'brand_id': body['brandId'],
'unit': body['unit'],
'is_active': body['isActive'],
},
);
final price = body['price'];
if (price is Map<String, dynamic>) {
await ctx.query(
'''
INSERT INTO prices (product_id, currency, price, old_price)
VALUES (@product_id, @currency, @price, @old_price)
ON CONFLICT (product_id)
DO UPDATE SET
currency = EXCLUDED.currency,
price = EXCLUDED.price,
old_price = EXCLUDED.old_price
''',
substitutionValues: {
'product_id': id,
'currency': price['currency'] ?? 'RUB',
'price': price['price'] ?? 0,
'old_price': price['oldPrice'],
},
);
}
final stock = body['stock'];
if (stock is Map<String, dynamic>) {
await ctx.query(
'''
INSERT INTO stocks (product_id, qty, reserved_qty)
VALUES (@product_id, @qty, @reserved_qty)
ON CONFLICT (product_id)
DO UPDATE SET
qty = EXCLUDED.qty,
reserved_qty = EXCLUDED.reserved_qty
''',
substitutionValues: {
'product_id': id,
'qty': stock['qty'] ?? 0,
'reserved_qty': stock['reservedQty'] ?? 0,
},
);
}
final attributes = body['attributes'];
if (attributes is List<dynamic>) {
await ctx.query(
'DELETE FROM product_attributes WHERE product_id = @id',
substitutionValues: {'id': id},
);
for (final attr in attributes.whereType<Map<String, dynamic>>()) {
await ctx.query(
'''
INSERT INTO product_attributes (id, product_id, key, value, unit)
VALUES (@id, @product_id, @key, @value, @unit)
''',
substitutionValues: {
'id': _uuid.v4(),
'product_id': id,
'key': attr['key'],
'value': attr['value'],
'unit': attr['unit'],
},
);
}
}
final images = body['images'];
if (images is List<dynamic>) {
await ctx.query(
'DELETE FROM product_images WHERE product_id = @id',
substitutionValues: {'id': id},
);
for (final image in images.whereType<Map<String, dynamic>>()) {
await ctx.query(
'''
INSERT INTO product_images (id, product_id, url, sort_order)
VALUES (@id, @product_id, @url, @sort_order)
''',
substitutionValues: {
'id': _uuid.v4(),
'product_id': id,
'url': image['url'],
'sort_order': image['sortOrder'] ?? 1,
},
);
}
}
});
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminDeleteProduct(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
await _db.query(
'UPDATE products SET is_active = FALSE, updated_at = NOW() WHERE id = @id',
substitutionValues: {'id': id},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminPatchStock(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
final qty = body['qty'];
final reservedQty = body['reservedQty'] ?? 0;
if (qty is! num || reservedQty is! num || qty < 0 || reservedQty < 0) {
return _error(
'validation_error',
'qty and reservedQty must be >= 0',
status: 400,
);
}
await _db.query(
'''
INSERT INTO stocks (product_id, qty, reserved_qty)
VALUES (@product_id, @qty, @reserved_qty)
ON CONFLICT (product_id)
DO UPDATE SET
qty = EXCLUDED.qty,
reserved_qty = EXCLUDED.reserved_qty
''',
substitutionValues: {
'product_id': id,
'qty': qty,
'reserved_qty': reservedQty,
},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
Future<Response> _adminPatchPrice(Request request, String id) async {
final forbidden = await _requireAdmin(request);
if (forbidden != null) {
return forbidden;
}
try {
final body = await _decodeBody(request);
final price = body['price'];
if (price is! num || price < 0) {
return _error('validation_error', 'price must be >= 0', status: 400);
}
await _db.query(
'''
INSERT INTO prices (product_id, currency, price, old_price)
VALUES (@product_id, @currency, @price, @old_price)
ON CONFLICT (product_id)
DO UPDATE SET
currency = EXCLUDED.currency,
price = EXCLUDED.price,
old_price = EXCLUDED.old_price
''',
substitutionValues: {
'product_id': id,
'currency': body['currency'] ?? 'RUB',
'price': price,
'old_price': body['oldPrice'],
},
);
return _json({'ok': true});
} catch (e) {
return _serverError(e);
}
}
}
Response _json(Object body, {int status = 200}) {
return Response(
status,
body: jsonEncode(body),
headers: {'content-type': 'application/json; charset=utf-8'},
);
}
Response _error(
String code,
String message, {
int status = 400,
List<Map<String, dynamic>> details = const <Map<String, dynamic>>[],
}) {
return _json({
'error': {'code': code, 'message': message, 'details': details},
}, status: status);
}
Response _serverError(Object error) {
return _error('internal_error', error.toString(), status: 500);
}
bool _boolArg(Request request, String name, bool defaultValue) {
final value = request.url.queryParameters[name];
if (value == null) {
return defaultValue;
}
return value.toLowerCase() == 'true';
}
bool? _nullableBoolArg(Request request, String name) {
final value = request.url.queryParameters[name];
if (value == null) {
return null;
}
return value.toLowerCase() == 'true';
}
int _intArg(
Request request,
String name,
int defaultValue, {
int min = 0,
int? max,
}) {
final raw = request.url.queryParameters[name];
final value = raw == null ? defaultValue : int.tryParse(raw) ?? defaultValue;
var safeValue = value;
if (safeValue < min) {
safeValue = min;
}
if (max != null && safeValue > max) {
safeValue = max;
}
return safeValue;
}
Future<Map<String, dynamic>> _decodeBody(Request request) async {
final body = await request.readAsString();
if (body.trim().isEmpty) {
return <String, dynamic>{};
}
return jsonDecode(body) as Map<String, dynamic>;
}
List<(String, String)> _parseAttrs(String? raw) {
if (raw == null || raw.isEmpty) {
return const [];
}
final attrs = <(String, String)>[];
for (final chunk in raw.split(';')) {
final entry = chunk.trim();
if (entry.isEmpty || !entry.contains(':')) {
continue;
}
final parts = entry.split(':');
if (parts.length < 2) {
continue;
}
attrs.add((parts.first.trim(), parts.sublist(1).join(':').trim()));
}
return attrs;
}
double _toDouble(Object? value) {
if (value == null) {
return 0;
}
if (value is num) {
return value.toDouble();
}
return double.tryParse(value.toString()) ?? 0;
}
double? _nullableDouble(Object? value) {
if (value == null) {
return null;
}
if (value is num) {
return value.toDouble();
}
return double.tryParse(value.toString());
}
int _toInt(Object? value) {
if (value == null) {
return 0;
}
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
return int.tryParse(value.toString()) ?? 0;
}