Files
recipe-app/flutter/lib/features/admin/data/admin_repository.dart
T
Nils-Johan Gynther 4492d7aa1c
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 50s
Test Suite / flutter-quality (push) Successful in 50s
feat: enhance receipt alias management with global scope support and update validation
2026-05-12 22:20:48 +02:00

588 lines
20 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;
List<AdminProduct>? _selectableProductsCache;
DateTime? _selectableProductsCacheAt;
static const Duration _selectableProductsCacheTtl = Duration(seconds: 45);
AdminRepository(this._apiClient, this._ref);
void _invalidateSelectableProductsCache() {
_selectableProductsCache = null;
_selectableProductsCacheAt = null;
}
// ── 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>> listSelectableProductsForAdmin({
bool forceRefresh = false,
}) async {
final now = DateTime.now();
final cached = _selectableProductsCache;
final cacheAt = _selectableProductsCacheAt;
if (!forceRefresh && cached != null && cacheAt != null) {
if (now.difference(cacheAt) <= _selectableProductsCacheTtl) {
return List<AdminProduct>.from(cached);
}
}
final results = await Future.wait<dynamic>([
listProducts(),
listPrivateProducts(),
]);
final globalProducts = results[0] as List<AdminProduct>;
final privateProducts = results[1] as List<PendingProduct>;
final merged = <int, AdminProduct>{
for (final product in globalProducts) product.id: product,
};
for (final product in privateProducts) {
merged[product.id] = AdminProduct(
id: product.id,
name: product.name,
canonicalName: product.canonicalName,
ownerId: product.ownerId,
isPrivate: true,
categoryId: product.categoryId,
categoryPath: product.categoryPath,
status: 'private',
);
}
final list = merged.values.toList();
list.sort(
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
_selectableProductsCache = List<AdminProduct>.from(list);
_selectableProductsCacheAt = now;
return list;
}
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}).then((_) {
_invalidateSelectableProductsCache();
});
Future<AdminProduct> promotePrivateProduct(int productId) =>
_post<AdminProduct>(
ProductApiPaths.promotePrivate(productId),
body: null,
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
).then((value) {
_invalidateSelectableProductsCache();
return value;
});
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) {
_invalidateSelectableProductsCache();
});
Future<void> removeProduct(int productId) =>
_deleteVoid(ProductApiPaths.remove(productId)).then((_) {
_invalidateSelectableProductsCache();
});
Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId)).then((_) {
_invalidateSelectableProductsCache();
});
// ── Product canonical name updates ────────────────────────────────────────
Future<void> updateCanonicalName(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalName(productId),
{'canonicalName': canonicalName.trim()},
).then((_) {
_invalidateSelectableProductsCache();
});
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalNamePrivate(productId),
{'canonicalName': canonicalName.trim()},
).then((_) {
_invalidateSelectableProductsCache();
});
// ── Product merging ────────────────────────────────────────────────────────
Future<void> mergeProductsPrivate({
required int sourceProductId,
required int targetProductId,
}) =>
_postVoid(ProductApiPaths.mergePrivate, {
'sourceProductId': sourceProductId,
'targetProductId': targetProductId,
}).then((_) {
_invalidateSelectableProductsCache();
});
/// 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,
},
).then((value) {
_invalidateSelectableProductsCache();
return value;
});
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,
).then((value) {
_invalidateSelectableProductsCache();
return value;
});
Future<void> mergeProducts({
required int sourceProductId,
required int targetProductId,
}) =>
_postVoid(ProductApiPaths.merge, {
'sourceProductId': sourceProductId,
'targetProductId': targetProductId,
}).then((_) {
_invalidateSelectableProductsCache();
});
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> updateReceiptAlias(
int id, {
String? receiptName,
int? productId,
bool? isGlobal,
}) {
final body = <String, dynamic>{
if (receiptName != null) 'receiptName': receiptName,
if (productId != null) 'productId': productId,
if (isGlobal != null) 'isGlobal': isGlobal,
};
return _patchVoid(ReceiptAliasApiPaths.update(id), body);
}
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<AdminPantryItem> createAdminPantry({
int? userId,
required int productId,
String? location,
}) {
return _post<AdminPantryItem>(
PantryApiPaths.adminCreate,
body: {
if (userId != null) 'userId': userId,
'productId': productId,
if (location != null && location.trim().isNotEmpty)
'location': location.trim(),
},
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
);
}
Future<AdminPantryItem> updateAdminPantry(
int pantryItemId, {
int? productId,
String? location,
}) {
return _patch(
PantryApiPaths.adminUpdate(pantryItemId),
body: {
if (productId != null) 'productId': productId,
if (location != null) 'location': location,
},
parse: AdminPantryItem.fromJson,
);
}
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),
);
}