feat(ai): add AI trace tracking and admin panel
- Add AiTrace model to Prisma schema with relations to User - Implement AiTraceService with CRUD operations for AI traces - Add new admin panel for AI traces with filtering and detail views - Integrate trace persistence in receipt import flow - Add API endpoints for listing and retrieving AI traces - Update Flutter admin UI with new AI tab and navigation - Add new domain models for AI traces and details - Add migration for AiTrace table creation BREAKING CHANGE: None
This commit is contained in:
@@ -9,6 +9,8 @@ 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/admin_ai_trace.dart';
|
||||
import '../domain/admin_ai_trace_detail.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
@@ -145,7 +147,8 @@ class AdminRepository {
|
||||
(data['data'] as List<dynamic>?) ??
|
||||
const [];
|
||||
if (raw.isEmpty && data.isNotEmpty) {
|
||||
debugPrint('[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
debugPrint(
|
||||
'[AdminRepository] Unexpected API wrapper shape: ${data.keys}');
|
||||
}
|
||||
} else {
|
||||
raw = const [];
|
||||
@@ -172,7 +175,8 @@ class AdminRepository {
|
||||
Future<UserAdmin> setRecipeSharing(int userId,
|
||||
{required bool canShareRecipes}) =>
|
||||
_patch(UserApiPaths.setRecipeSharing(userId),
|
||||
body: {'canShareRecipes': canShareRecipes}, parse: UserAdmin.fromJson);
|
||||
body: {'canShareRecipes': canShareRecipes},
|
||||
parse: UserAdmin.fromJson);
|
||||
|
||||
Future<void> updateEmail(int userId, String email) =>
|
||||
_patchVoid(UserApiPaths.updateEmail(userId), {'email': email});
|
||||
@@ -194,7 +198,8 @@ class AdminRepository {
|
||||
parse: (d) => UserAdmin.fromJson(d as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Future<void> deleteUser(int userId) => _deleteVoid(UserApiPaths.delete(userId));
|
||||
Future<void> deleteUser(int userId) =>
|
||||
_deleteVoid(UserApiPaths.delete(userId));
|
||||
|
||||
/// Returns `{ temporaryPassword, to, subject, body }`.
|
||||
Future<Map<String, dynamic>> resetPassword(int userId) =>
|
||||
@@ -203,7 +208,8 @@ class AdminRepository {
|
||||
// ── Produkter ──────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminProduct>> listProducts() =>
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson,
|
||||
requiresAuth: false);
|
||||
|
||||
@Deprecated('Use listProducts(). Kept for temporary compatibility.')
|
||||
Future<List<AdminProduct>> listGlobalProducts() => listProducts();
|
||||
@@ -249,7 +255,8 @@ class AdminRepository {
|
||||
|
||||
final list = merged.values.toList();
|
||||
list.sort(
|
||||
(a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
_selectableProductsCache = List<AdminProduct>.from(list);
|
||||
_selectableProductsCacheAt = now;
|
||||
@@ -263,7 +270,8 @@ class AdminRepository {
|
||||
_getList(ProductApiPaths.pending, PendingProduct.fromJson);
|
||||
|
||||
Future<void> setProductStatus(int productId, String status) =>
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) {
|
||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -271,14 +279,16 @@ class AdminRepository {
|
||||
_post<AdminProduct>(
|
||||
ProductApiPaths.promotePrivate(productId),
|
||||
body: null,
|
||||
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
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((_) {
|
||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId})
|
||||
.then((_) {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
@@ -301,7 +311,8 @@ class AdminRepository {
|
||||
_invalidateSelectableProductsCache();
|
||||
});
|
||||
|
||||
Future<void> updateCanonicalNamePrivate(int productId, String canonicalName) =>
|
||||
Future<void> updateCanonicalNamePrivate(
|
||||
int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalNamePrivate(productId),
|
||||
{'canonicalName': canonicalName.trim()},
|
||||
@@ -336,7 +347,8 @@ class AdminRepository {
|
||||
|
||||
int _parseUpdatedCount(dynamic data) {
|
||||
if (data is! Map) {
|
||||
debugPrint('[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
debugPrint(
|
||||
'[AdminRepository] bulkSetCategory unexpected response type: ${data.runtimeType}');
|
||||
return 0;
|
||||
}
|
||||
final map = Map<String, dynamic>.from(data);
|
||||
@@ -391,8 +403,7 @@ class AdminRepository {
|
||||
|
||||
// ── Kategorier ─────────────────────────────────────────────────────────────
|
||||
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() =>
|
||||
_getList(
|
||||
Future<List<AdminCategoryNode>> listCategoryTree() => _getList(
|
||||
CategoryApiPaths.tree,
|
||||
AdminCategoryNode.fromJson,
|
||||
requiresAuth: false,
|
||||
@@ -404,6 +415,26 @@ class AdminRepository {
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
||||
|
||||
Future<AdminAiTraceListResponse> listAiTraces({
|
||||
required AdminAiTraceSource source,
|
||||
int limit = 25,
|
||||
String? cursor,
|
||||
String? period,
|
||||
bool onlyErrors = false,
|
||||
}) =>
|
||||
_getMap(
|
||||
AiApiPaths.traces(
|
||||
source: source.apiValue,
|
||||
limit: limit,
|
||||
cursor: cursor,
|
||||
period: period,
|
||||
onlyErrors: onlyErrors,
|
||||
),
|
||||
).then(AdminAiTraceListResponse.fromJson);
|
||||
|
||||
Future<AdminAiTraceDetail> getAiTraceById(String traceId) =>
|
||||
_getMap(AiApiPaths.traceById(traceId)).then(AdminAiTraceDetail.fromJson);
|
||||
|
||||
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
|
||||
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
@@ -543,7 +574,8 @@ class AdminRepository {
|
||||
if (location != null && location.trim().isNotEmpty)
|
||||
'location': location.trim(),
|
||||
},
|
||||
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
parse: (d) =>
|
||||
AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,6 +614,7 @@ class AdminRepository {
|
||||
required int targetInventoryId,
|
||||
}) =>
|
||||
_getMap(
|
||||
AdminInventoryApiPaths.mergePreview(sourceInventoryId, targetInventoryId),
|
||||
AdminInventoryApiPaths.mergePreview(
|
||||
sourceInventoryId, targetInventoryId),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user