453 lines
16 KiB
Dart
453 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 typad 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 typad 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),
|
|
);
|
|
}
|
|
|
|
/// 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) =>
|
|
_post<Map<String, dynamic>>(
|
|
UserApiPaths.resetPassword(userId),
|
|
body: null,
|
|
parse: (d) => d as Map<String, dynamic>,
|
|
);
|
|
|
|
// ── Produkter ──────────────────────────────────────────────────────────────
|
|
|
|
Future<List<AdminProduct>> listProducts() =>
|
|
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
|
|
|
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 ────────────────────────────────────────
|
|
// Admin can update any product; users can only update their own private products
|
|
|
|
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 ────────────────────────────────────────────────────────
|
|
// Admin can merge any products; users can only merge their own private products
|
|
|
|
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}) =>
|
|
_post<Map<String, dynamic>>(
|
|
ProductApiPaths.list,
|
|
body: {
|
|
'name': name.trim(),
|
|
if (categoryId != null) 'categoryId': categoryId,
|
|
},
|
|
parse: (d) => d as Map<String, dynamic>,
|
|
);
|
|
|
|
Future<int> bulkSetCategory(List<int> ids, {required int? categoryId}) =>
|
|
_post<int>(
|
|
ProductApiPaths.bulkUpdate,
|
|
body: {'ids': ids, 'categoryId': categoryId},
|
|
parse: (d) {
|
|
final map = Map<String, dynamic>.from(d as Map);
|
|
return (map['updated'] as num?)?.toInt() ?? 0;
|
|
},
|
|
);
|
|
|
|
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,
|
|
}) async {
|
|
final token = await _token();
|
|
final data = await guardedApiCall(
|
|
_ref,
|
|
() => _apiClient.getJson(
|
|
ProductApiPaths.mergePreview(sourceProductId, targetProductId),
|
|
token: token,
|
|
),
|
|
);
|
|
return Map<String, dynamic>.from(data as Map);
|
|
}
|
|
|
|
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,
|
|
}) async {
|
|
final token = await _token();
|
|
final data = await guardedApiCall(
|
|
_ref,
|
|
() => _apiClient.getJson(
|
|
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
|
token: token,
|
|
),
|
|
);
|
|
return Map<String, dynamic>.from(data as Map);
|
|
}
|
|
}
|