Files
recipe-app/flutter/lib/features/admin/data/admin_repository.dart
T
2026-05-11 10:40:54 +02:00

462 lines
16 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart';
import '../../auth/data/auth_providers.dart';
import '../domain/admin_ai_categorize_result.dart';
import '../domain/admin_category_node.dart';
import '../domain/admin_pantry_item.dart';
import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.dart';
import '../domain/ai_model_info.dart';
import '../domain/pending_product.dart';
import '../domain/receipt_alias.dart';
import '../domain/user_admin.dart';
final adminRepositoryProvider = Provider<AdminRepository>((ref) {
return AdminRepository(ref.watch(apiClientProvider), ref);
});
class AdminRepository {
final ApiClient _apiClient;
final Ref _ref;
AdminRepository(this._apiClient, this._ref);
// ── Interna helpers ────────────────────────────────────────────────────────
Future<String?> _token() => _ref.read(authStateProvider.future);
/// GET-anrop som returnerar en typad lista med [fromJson].
Future<List<T>> _getList<T>(
String path,
T Function(Map<String, dynamic>) fromJson, {
bool requiresAuth = true,
}) async {
final token = requiresAuth ? await _token() : null;
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(path, token: token),
);
return _parseList(data, fromJson);
}
/// POST-anrop som returnerar ett typat objekt med [fromJson].
Future<T> _post<T>(
String path, {
required Map<String, dynamic>? body,
required T Function(dynamic) parse,
bool requiresAuth = true,
}) async {
final token = requiresAuth ? await _token() : null;
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(path, body: body, token: token),
);
return parse(data);
}
/// PATCH-anrop som returnerar ett typat objekt med [fromJson].
Future<T> _patch<T>(
String path, {
required Map<String, dynamic> body,
required T Function(Map<String, dynamic>) parse,
}) async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.patchJson(path, body: body, token: token),
);
return parse(Map<String, dynamic>.from(data as Map));
}
/// Fire-and-forget PATCH.
Future<void> _patchVoid(String path, Map<String, dynamic> body) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.patchJson(path, body: body, token: token),
);
}
/// Fire-and-forget POST.
Future<void> _postVoid(String path, [Map<String, dynamic>? body]) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(path, body: body, token: token),
);
}
/// Fire-and-forget DELETE.
Future<void> _deleteVoid(String path) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.deleteJson(path, token: token),
);
}
Future<Map<String, dynamic>> _getMap(
String path, {
bool requiresAuth = true,
}) async {
final token = requiresAuth ? await _token() : null;
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(path, token: token),
);
return Map<String, dynamic>.from(data as Map);
}
Future<Map<String, dynamic>> _postMap(
String path, {
Map<String, dynamic>? body,
bool requiresAuth = true,
}) async {
final token = requiresAuth ? await _token() : null;
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(path, body: body, token: token),
);
return Map<String, dynamic>.from(data as Map);
}
/// Tolerant listparsning — accepterar ren lista eller wrapper ({items, data}).
static List<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
final List<dynamic> raw;
if (data is List<dynamic>) {
raw = data;
} else if (data is Map<String, dynamic>) {
raw = (data['items'] as List<dynamic>?) ??
(data['data'] as List<dynamic>?) ??
const [];
if (raw.isEmpty && data.isNotEmpty) {
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
}
} else {
raw = const [];
}
return raw
.whereType<Map>()
.map((e) => fromJson(Map<String, dynamic>.from(e)))
.toList();
}
// ── Användare ──────────────────────────────────────────────────────────────
Future<List<UserAdmin>> listUsers() =>
_getList(UserApiPaths.list, UserAdmin.fromJson);
Future<UserAdmin> setRole(int userId, String newRole) =>
_patch(UserApiPaths.setRole(userId),
body: {'role': newRole}, parse: UserAdmin.fromJson);
Future<UserAdmin> setPremium(int userId, {required bool isPremium}) =>
_patch(UserApiPaths.setPremium(userId),
body: {'isPremium': isPremium}, parse: UserAdmin.fromJson);
Future<UserAdmin> setRecipeSharing(int userId,
{required bool canShareRecipes}) =>
_patch(UserApiPaths.setRecipeSharing(userId),
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
Future<void> updateEmail(int userId, String email) =>
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
Future<UserAdmin> createUser({
required String username,
required String email,
required String password,
String role = 'user',
}) =>
_post(
UserApiPaths.list,
body: {
'username': username,
'email': email,
'password': password,
'role': role,
},
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
);
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
/// Returns `{ temporaryPassword, to, subject, body }`.
Future<Map<String, dynamic>> resetPassword(int userId) =>
_postMap(UserApiPaths.resetPassword(userId), body: null);
// ── Produkter ──────────────────────────────────────────────────────────────
Future<List<AdminProduct>> listProducts() =>
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
@Deprecated('Use listProducts(). Kept for temporary compatibility.')
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
Future<List<PendingProduct>> listPrivateProducts() =>
_getList(ProductApiPaths.privateList, PendingProduct.fromJson);
Future<List<AdminProduct>> listDeletedProducts() =>
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
Future<List<PendingProduct>> listPendingProducts() =>
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
Future<void> setProductStatus(int productId, String status) =>
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
Future<AdminProduct> promotePrivateProduct(int productId) =>
_post<AdminProduct>(
ProductApiPaths.promotePrivate(productId),
body: null,
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
);
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
Future<void> removeProduct(int productId) =>
_deleteVoid(ProductApiPaths.remove(productId));
Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId));
// ── Product canonical name updates ────────────────────────────────────────
Future<void> updateCanonicalName(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalName(productId),
{'canonicalName': canonicalName.trim()},
);
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalNamePrivate(productId),
{'canonicalName': canonicalName.trim()},
);
// ── Product merging ────────────────────────────────────────────────────────
Future<void> mergeProductsPrivate({
required int sourceProductId,
required int targetProductId,
}) =>
_postVoid(ProductApiPaths.mergePrivate, {
'sourceProductId': sourceProductId,
'targetProductId': targetProductId,
});
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
_postMap(
ProductApiPaths.list,
body: {
'name': name.trim(),
if (categoryId != null) 'categoryId': categoryId,
},
);
int _parseUpdatedCount(dynamic data) {
if (data is! Map) {
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
return 0;
}
final map = Map<String, dynamic>.from(data);
return (map['updated'] as num?)?.toInt() ?? 0;
}
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
_post<int>(
ProductApiPaths.bulkUpdate,
body: {'ids': ids, 'categoryId': categoryId},
parse: _parseUpdatedCount,
);
Future<void> mergeProducts({
required int sourceProductId,
required int targetProductId,
}) =>
_postVoid(ProductApiPaths.merge, {
'sourceProductId': sourceProductId,
'targetProductId': targetProductId,
});
Future<Map<String, dynamic>> previewMerge({
required int sourceProductId,
required int targetProductId,
}) =>
_getMap(ProductApiPaths.mergePreview(sourceProductId, targetProductId));
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
List<int>? productIds,
}) async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.postJson(
ProductApiPaths.aiCategorizeBulk,
body: (productIds == null || productIds.isEmpty)
? null
: {'productIds': productIds},
token: token,
),
);
return _parseList(data, AdminAiCategorizeResult.fromJson)
.where((e) => e.productId > 0 && e.categoryId > 0)
.toList();
}
// ── Kategorier ─────────────────────────────────────────────────────────────
Future<List<AdminCategoryNode>> listCategoryTree() =>
_getList(
CategoryApiPaths.tree,
AdminCategoryNode.fromJson,
requiresAuth: false,
);
// ── AI-modeller ────────────────────────────────────────────────────────────
/// OBS: endpointen /ai/models kräver autentisering.
Future<List<AiModelInfo>> listAiModels() =>
_getList(AiApiPaths.models, AiModelInfo.fromJson);
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
Future<List<ReceiptAlias>> listReceiptAliases() =>
_getList(ReceiptAliasApiPaths.list, ReceiptAlias.fromJson);
Future<void> upsertReceiptAlias({
required String receiptName,
required int productId,
bool isGlobal = false,
}) =>
_postVoid(ReceiptAliasApiPaths.list, {
'receiptName': receiptName,
'productId': productId,
'isGlobal': isGlobal,
});
Future<void> removeReceiptAlias(int id) =>
_deleteVoid(ReceiptAliasApiPaths.remove(id));
// ── Admin inventory (global tabellhantering) ─────────────────────────────
Future<List<AdminInventoryItem>> listAdminInventory({
int? userId,
String? sort,
}) {
final path = AdminInventoryApiPaths.withFilters(userId: userId, sort: sort);
return _getList(path, AdminInventoryItem.fromJson);
}
Future<AdminInventoryItem> createAdminInventory({
int? userId,
required int productId,
required double quantity,
required String unit,
String? location,
String? brand,
String? receiptName,
String? suitableFor,
String? comment,
}) {
return _post<AdminInventoryItem>(
AdminInventoryApiPaths.list,
body: {
if (userId != null) 'userId': userId,
'productId': productId,
'quantity': quantity,
'unit': unit,
if (location != null && location.trim().isNotEmpty)
'location': location.trim(),
if (brand != null && brand.trim().isNotEmpty) 'brand': brand.trim(),
if (receiptName != null && receiptName.trim().isNotEmpty)
'receiptName': receiptName.trim(),
if (suitableFor != null && suitableFor.trim().isNotEmpty)
'suitableFor': suitableFor.trim(),
if (comment != null && comment.trim().isNotEmpty)
'comment': comment.trim(),
},
parse: (d) =>
AdminInventoryItem.fromJson(Map<String, dynamic>.from(d as Map)),
);
}
Future<AdminInventoryItem> updateAdminInventory(
int inventoryId, {
int? productId,
double? quantity,
String? unit,
String? location,
String? brand,
String? receiptName,
String? suitableFor,
String? comment,
}) {
final body = <String, dynamic>{
if (productId != null) 'productId': productId,
if (quantity != null) 'quantity': quantity,
if (unit != null) 'unit': unit,
if (location != null) 'location': location,
if (brand != null) 'brand': brand,
if (receiptName != null) 'receiptName': receiptName,
if (suitableFor != null) 'suitableFor': suitableFor,
if (comment != null) 'comment': comment,
};
return _patch(
AdminInventoryApiPaths.update(inventoryId),
body: body,
parse: AdminInventoryItem.fromJson,
);
}
Future<void> removeAdminInventory(int inventoryId) =>
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
_postVoid(AdminInventoryApiPaths.moveToPantry(inventoryId));
// ── Admin pantry ──────────────────────────────────────────────────────────
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) {
final params = <String, String>{};
if (userId != null) params['userId'] = '$userId';
final path = params.isEmpty
? PantryApiPaths.adminList
: '${PantryApiPaths.adminList}?${params.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&')}';
return _getList(path, AdminPantryItem.fromJson);
}
Future<void> removeAdminPantryItem(int pantryItemId) =>
_deleteVoid(PantryApiPaths.adminRemove(pantryItemId));
Future<void> moveAdminPantryToInventory(
int pantryItemId,
Map<String, dynamic> body,
) =>
_postVoid(PantryApiPaths.moveToInventoryAdmin(pantryItemId), body);
Future<void> mergeAdminInventory({
required int sourceInventoryId,
required int targetInventoryId,
}) =>
_postVoid(AdminInventoryApiPaths.merge, {
'sourceInventoryId': sourceInventoryId,
'targetInventoryId': targetInventoryId,
});
Future<Map<String, dynamic>> previewAdminInventoryMerge({
required int sourceInventoryId,
required int targetInventoryId,
}) =>
_getMap(
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
);
}