feat(receipt-import): add refresh categories endpoint and UI integration

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Nils-Johan Gynther
2026-05-03 10:48:06 +02:00
parent 6503d29801
commit b2eb870ec7
7 changed files with 79 additions and 16 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Schemalagd Uppdatering av Kategorier",
"type": "shell",
"command": "echo \"0 0 * * 1 cd /opt/containers/recipe-app && node -e \\\"require('./dist/src/receipt-import/receipt-import.service').loadCategories()\\\"\" | crontab -",
"isBackground": false,
"problemMatcher": []
}
]
}
@@ -12,6 +12,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { AuthGuard } from '@nestjs/passport';
const ALLOWED_MIMES = [
'image/jpeg',
@@ -52,4 +53,11 @@ export class ReceiptImportController {
const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
return this.receiptImportService.parseReceipt(file, isPremium, userId);
}
@Post('refresh-categories')
@UseGuards(AuthGuard('jwt'))
async refreshCategories() {
await this.receiptImportService.loadCategories();
return { message: 'Kategorier har uppdaterats.' };
}
}
@@ -102,12 +102,21 @@ function inferPackageDebugFromRawName(rawName: string): {
@Injectable()
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
private cachedCategories: any[] = [];
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
) {
this.loadCategories();
}
async loadCategories() {
this.cachedCategories = await this.prisma.category.findMany({
include: { children: true },
});
}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer
@@ -149,12 +158,8 @@ export class ReceiptImportService {
} catch {
// ignorera parse-fel
}
this.logger.error(`Importer-api kvittoparsfel: ${message}`);
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
return response.json() as Promise<ParsedReceiptItem[]>;
}
-10
View File
@@ -360,12 +360,6 @@ INSERT INTO `Category` (`name`, `parentId`)
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Allergi mejeri'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Mejeri, ost & ägg > Mjölk (standard) ───────────────
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Standardmjölk', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Mjölk'
WHERE c1.name = 'Mejeri, ost & ägg' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Mejeri, ost & ägg > Matlagning ──────────────────
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Gräddfil & creme fraiche', c2.id FROM `Category` c1
@@ -491,10 +485,6 @@ INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Färdigmat enportioner', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat'
WHERE c1.name = 'Fryst' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Pizza, paj & piroger', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Färdigmat'
WHERE c1.name = 'Fryst' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Skafferi > Bakning ────────────────────────
INSERT INTO `Category` (`name`, `parentId`)
+4
View File
@@ -25,6 +25,10 @@ class CategoryApiPaths {
static const tree = '/categories/tree';
}
class ReceiptImportApiPaths {
static const refreshCategories = '/receipt-import/refresh-categories';
}
class RecipeApiPaths {
static const list = '/recipes';
static String detail(int id) => '/recipes/$id';
@@ -41,4 +41,12 @@ class ProfileRepository {
);
return UserProfile.fromJson(data);
}
Future<void> refreshCategories() async {
final token = await _ref.read(authStateProvider.future);
await guardedApiCall(
_ref,
() => _apiClient.postJson(ReceiptImportApiPaths.refreshCategories, body: {}, token: token),
);
}
}
@@ -27,6 +27,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = true;
bool _isSaving = false;
bool _isRefreshingCategories = false;
String? _error;
UserProfile? _profile;
_ProfileTab _activeTab = _ProfileTab.profile;
@@ -105,6 +106,24 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
context.go('/login');
}
Future<void> _refreshCategories() async {
setState(() => _isRefreshingCategories = true);
try {
await ref.read(profileRepositoryProvider).refreshCategories();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Kategorier har uppdaterats.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
);
} finally {
if (mounted) setState(() => _isRefreshingCategories = false);
}
}
List<_ProfileTab> _visibleTabs(bool isAdmin) {
return [
_ProfileTab.profile,
@@ -212,6 +231,23 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
: Text(context.l10n.profileSaveAction),
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isRefreshingCategories ? null : _refreshCategories,
icon: _isRefreshingCategories
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
label: const Text('Uppdatera kategorier'),
),
),
],
),
);