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 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 _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 Function() _connector; PostgreSQLConnection? _connection; Future? _pendingConnection; Future _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>> query( String query, { Map? substitutionValues, }) async { final connection = await _getConnection(); return connection.query(query, substitutionValues: substitutionValues); } Future transaction( Future 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/', _getCategory) ..get('/api/v1/brands', _getBrands) ..get('/api/v1/products', _getProducts) ..get('/api/v1/products/facets', _getProductFacets) ..get('/api/v1/products/', _getProductById) ..post('/api/v1/auth/login', _login) ..post('/api/v1/admin/categories', _adminCreateCategory) ..patch('/api/v1/admin/categories/', _adminPatchCategory) ..delete('/api/v1/admin/categories/', _adminDeleteCategory) ..post('/api/v1/admin/brands', _adminCreateBrand) ..patch('/api/v1/admin/brands/', _adminPatchBrand) ..delete('/api/v1/admin/brands/', _adminDeleteBrand) ..post('/api/v1/admin/products', _adminCreateProduct) ..patch('/api/v1/admin/products/', _adminPatchProduct) ..delete('/api/v1/admin/products/', _adminDeleteProduct) ..patch('/api/v1/admin/products//stock', _adminPatchStock) ..patch('/api/v1/admin/products//price', _adminPatchPrice); } Future _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 = >>{}; for (final item in items) { final parentId = item['parentId'] as String?; byParent.putIfAbsent(parentId, () => []).add(item); } List> 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 _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 = >>{}; for (final item in items) { final parentId = item['parentId'] as String?; byParent.putIfAbsent(parentId, () => []).add(item); } Map 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 _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 _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 = ['p.is_active = TRUE']; final params = {}; 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 _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 _getProductFacets(Request request) async { try { final base = await _getProducts(request); if (base.statusCode != 200) { return base; } final payload = jsonDecode(await base.readAsString()) as Map; final items = (payload['items'] as List).cast>(); 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 = >>{}; 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 _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 _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 _requireAdmin(Request request) async { if (await _isAdmin(request)) { return null; } return _error('unauthorized', 'Admin token required', status: 401); } Future _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 _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 _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 _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 _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 _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 _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?; 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?; 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? ?? []) .whereType>() .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? ?? []) .whereType>() .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 _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) { 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) { 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) { await ctx.query( 'DELETE FROM product_attributes WHERE product_id = @id', substitutionValues: {'id': id}, ); for (final attr in attributes.whereType>()) { 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) { await ctx.query( 'DELETE FROM product_images WHERE product_id = @id', substitutionValues: {'id': id}, ); for (final image in images.whereType>()) { 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 _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 _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 _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> details = const >[], }) { 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> _decodeBody(Request request) async { final body = await request.readAsString(); if (body.trim().isEmpty) { return {}; } return jsonDecode(body) as Map; } 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; }