feat(ai): add AI trace tracking and admin panel
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 12m45s
Test Suite / flutter-quality (push) Failing after 7m24s

- 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:
Nils-Johan Gynther
2026-05-21 17:33:21 +02:00
parent c3520b5ad4
commit 67a7590525
21 changed files with 2477 additions and 509 deletions
@@ -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),
);
}