chore(ci): update project documentation and flyer import features

Update project documentation with recent CI improvements and flyer import enhancements:

- Add ESLint configuration for backend and Dart lints for Flutter
- Document Prisma query logging via PRISMA_LOG_QUERIES environment variable
- Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features
- Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem
- Update FlyerImportResponse type to include new fields
- Extend file picker to support image formats (png, jpg, jpeg, webp)
- Add offer badge display and price formatting in Flutter UI
- Implement PDF preview functionality for flyer import
This commit is contained in:
Nils-Johan Gynther
2026-05-18 23:27:20 +02:00
parent 3f242f9a6d
commit e658f2e6f1
7 changed files with 563 additions and 388 deletions
+8
View File
@@ -43,6 +43,14 @@ MVP ar uppnadd nar en vanlig anvandare kan importera, granska och spara kvitto/r
## Nyligen klart ## Nyligen klart
## Utförda steg (2026-05-18)
- [x] **ESLint i backend + CI:** ESLint-konfiguration tillagd i backend och CI-workflow uppdaterad med lint-step för PR/push.
- [x] **Dart lint-konfig aktiverad:** `flutter/analysis_options.yaml` tillagd för att säkerställa `flutter_lints` i analyskörningar.
- [x] **Prisma query logging styrbar per miljö:** `PRISMA_LOG_QUERIES` implementerad i backend samt kopplad i `compose.yml`.
- [x] **Dokumenterat aktivering av query-loggar:** Instruktion att sätta `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` i test/staging.
- [x] **Korrigerat testförväntan i receipt-import:** Security-test för saknat användar-id uppdaterat till `UnauthorizedException`.
## Utförda steg (2026-05-13) ## Utförda steg (2026-05-13)
- [x] **Centralt hjälptextsystem (backend):** Nytt `HelpTextsModule` med service, controller och DTO. `GET /api/help-texts/:key` returnerar rätt hjälptext baserat på användarroll (prioritetsordning: admin → user → default). `PUT /api/help-texts/:key/:scope` kräver admin-roll. - [x] **Centralt hjälptextsystem (backend):** Nytt `HelpTextsModule` med service, controller och DTO. `GET /api/help-texts/:key` returnerar rätt hjälptext baserat på användarroll (prioritetsordning: admin → user → default). `PUT /api/help-texts/:key/:scope` kräver admin-roll.
+8
View File
@@ -1,4 +1,12 @@
# Nyheter och förbättringar (2026-05-18)
- **CI: ESLint för backend:** ESLint är infört i backend (`backend/eslint.config.mjs`) och körs i GitHub Actions (`.github/workflows/test.yml`) via steget `Lint backend`.
- **CI: Dart lints aktiverade:** `flutter/analysis_options.yaml` är tillagd med `include: package:flutter_lints/flutter.yaml`, så `flutter analyze` använder explicita lint-regler.
- **Prisma query logging i test/staging:** Backend stödjer nu env-styrd query-loggning via `PRISMA_LOG_QUERIES` i `backend/src/prisma/prisma.service.ts`.
- **Compose-stöd för loggning:** `compose.yml` har `PRISMA_LOG_QUERIES: "${PRISMA_LOG_QUERIES:-0}"` för säker default av.
- **Testfix receipt-import:** Säkerhetstestet för saknat användar-id i `upsertUnitMapping` är uppdaterat till `UnauthorizedException`, i linje med controllerns beteende.
# Nyheter och förbättringar (2026-05-13) # Nyheter och förbättringar (2026-05-13)
- **Centralt hjälptextsystem:** Nytt backend-modul (`HelpTextsModule`) med `GET /api/help-texts/:key` (rollmedveten) och `PUT /api/help-texts/:key/:scope` (admin). Stöd för scopade hjälptexter: `admin`, `user`, `default` med prioritetsordning beroende på användarroll. - **Centralt hjälptextsystem:** Nytt backend-modul (`HelpTextsModule`) med `GET /api/help-texts/:key` (rollmedveten) och `PUT /api/help-texts/:key/:scope` (admin). Stöd för scopade hjälptexter: `admin`, `user`, `default` med prioritetsordning beroende på användarroll.
+9
View File
@@ -16,6 +16,15 @@ Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.
# Prisma-migreringar: P3009 recovery och lessons learned # Prisma-migreringar: P3009 recovery och lessons learned
# Nyheter och förbättringar (2026-05-18)
- **Backend linting i CI:** ESLint är infört för backend (`backend/eslint.config.mjs`, `npm run lint`) och körs i `.github/workflows/test.yml`.
- **Flutter lint-konfiguration:** `flutter/analysis_options.yaml` är tillagd och inkluderar `package:flutter_lints/flutter.yaml`.
- **Prisma query logging (miljöstyrd):** `PrismaService` konfigurerar loggnivåer via env-variabeln `PRISMA_LOG_QUERIES`.
- **Runtime-konfiguration:** `compose.yml` exponerar `PRISMA_LOG_QUERIES` till `recipe-api` med default `0`.
- **Aktivering i testmiljö:** Sätt `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` för att få SQL query-loggar.
- **Verifierad testjustering:** `receipt-import.security.spec.ts` validerar nu `UnauthorizedException` vid saknat användar-id i `upsertUnitMapping`.
# Drift och deploy (2026-05-11) # Drift och deploy (2026-05-11)
- **Flutter build-artifacts:** Byggda filer i `flutter/build/` och `.flutter-plugins-dependencies` ska inte versionshanteras. Vid deploy på server: kör `git restore flutter/build flutter/.flutter-plugins-dependencies` och `git clean -fd flutter/build` innan `git pull`. - **Flutter build-artifacts:** Byggda filer i `flutter/build/` och `.flutter-plugins-dependencies` ska inte versionshanteras. Vid deploy på server: kör `git restore flutter/build flutter/.flutter-plugins-dependencies` och `git clean -fd flutter/build` innan `git pull`.
@@ -10,6 +10,8 @@ export type FlyerImportItem = {
comparisonPrice: number | null; comparisonPrice: number | null;
comparisonUnit: string | null; comparisonUnit: string | null;
offerText: string | null; offerText: string | null;
isOffer: boolean;
offerLimitText: string | null;
parseConfidence: number; parseConfidence: number;
parseReasons: string[]; parseReasons: string[];
matchedProductId: number | null; matchedProductId: number | null;
@@ -79,6 +79,7 @@ export class FlyerImportService {
const items: FlyerImportItem[] = parsed.items.map((item) => { const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById); const match = this.matchItem(item, products, aliasToProduct, productById);
const offerLimitText = this.extractOfferLimitText(item.offerText);
return { return {
flyerItemId: null, flyerItemId: null,
rawName: item.rawName, rawName: item.rawName,
@@ -89,6 +90,8 @@ export class FlyerImportService {
comparisonPrice: item.comparisonPrice, comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit, comparisonUnit: item.comparisonUnit,
offerText: item.offerText, offerText: item.offerText,
isOffer: this.isOfferItem(item),
offerLimitText,
parseConfidence: item.confidence, parseConfidence: item.confidence,
parseReasons: item.reasonCodes, parseReasons: item.reasonCodes,
matchedProductId: match.product?.id ?? null, matchedProductId: match.product?.id ?? null,
@@ -257,6 +260,29 @@ export class FlyerImportService {
return intersection / union; return intersection / union;
} }
private isOfferItem(item: FlyerParseItem): boolean {
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim();
}
private extractOfferLimitText(offerText: string | null): string | null {
if (!offerText) return null;
const normalized = offerText.replace(/\s+/g, ' ' ).trim();
if (!normalized) return null;
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i);
if (limitMatch?.[0]) {
return limitMatch[0].trim();
}
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
if (householdMatch?.[0]) {
return householdMatch[0].trim();
}
return null;
}
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> { private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData(); const form = new FormData();
form.append( form.append(
@@ -6,6 +6,12 @@ class FlyerImportItem {
final double? price; final double? price;
final String? priceUnit; final String? priceUnit;
final String? offerText; final String? offerText;
final bool isOffer;
final String? offerLimitText;
final double? comparisonPrice;
final String? comparisonUnit;
final double? parseConfidence;
final List<String> parseReasons;
final int? matchedProductId; final int? matchedProductId;
final String? matchedProductName; final String? matchedProductName;
final String? matchedVia; final String? matchedVia;
@@ -19,6 +25,12 @@ class FlyerImportItem {
this.price, this.price,
this.priceUnit, this.priceUnit,
this.offerText, this.offerText,
this.isOffer = false,
this.offerLimitText,
this.comparisonPrice,
this.comparisonUnit,
this.parseConfidence,
this.parseReasons = const [],
this.matchedProductId, this.matchedProductId,
this.matchedProductName, this.matchedProductName,
this.matchedVia, this.matchedVia,
@@ -34,6 +46,12 @@ class FlyerImportItem {
price: (json['price'] as num?)?.toDouble(), price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?, priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?, offerText: json['offerText'] as String?,
isOffer: json['isOffer'] == true,
offerLimitText: json['offerLimitText'] as String?,
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
comparisonUnit: json['comparisonUnit'] as String?,
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
matchedProductId: (json['matchedProductId'] as num?)?.toInt(), matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?, matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?, matchedVia: json['matchedVia'] as String?,
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart'; import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart'; import '../domain/flyer_import_item.dart';
@@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
Future<void> _pickFile() async { Future<void> _pickFile() async {
final result = await FilePicker.pickFiles( final result = await FilePicker.pickFiles(
type: FileType.custom, type: FileType.custom,
allowedExtensions: ['pdf', 'txt'], allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'],
withData: true, withData: true,
); );
if (result == null || result.files.isEmpty) return; if (result == null || result.files.isEmpty) return;
@@ -108,6 +109,91 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
} }
} }
String _formatPrice(double? price, String? unit) {
if (price == null) return '';
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
return '$raw kr$unitPart';
}
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
if (!hasOffer) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
'ERBJUDANDE',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.red.shade900,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _buildFlyerPreview(ThemeData theme) {
final file = _pickedFile;
final bytes = file?.bytes;
if (bytes == null) return const SizedBox.shrink();
final filename = file?.name ?? '';
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
final ext = (file?.extension ?? fallbackExt).toLowerCase();
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
leading: Icon(
isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined,
color: theme.colorScheme.primary,
),
title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''),
trailing: isImage
? null
: OutlinedButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Visa flyer'),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final opened = await openPdfBytes(bytes);
if (!context.mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
),
);
},
),
),
if (isImage)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
)
else
const SizedBox(height: 8),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.', 'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile, onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file), icon: const Icon(Icons.attach_file),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'), label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
@@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
icon: const Icon(Icons.auto_awesome), icon: const Icon(Icons.auto_awesome),
label: const Text('Importera flyer'), label: const Text('Importera flyer'),
), ),
const SizedBox(height: 12),
_buildFlyerPreview(theme),
if (_isLoading) ...[ if (_isLoading) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
const LinearProgressIndicator(), const LinearProgressIndicator(),
@@ -154,7 +242,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
} }
}); });
}, },
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
), ),
], ],
), ),
@@ -162,14 +250,29 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
...items.asMap().entries.map((entry) { ...items.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final item = entry.value; final item = entry.value;
final priceText = _formatPrice(item.price, item.priceUnit);
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
final limitText = item.offerLimitText?.trim();
return CheckboxListTile( return CheckboxListTile(
value: _selected[index] ?? false, value: _selected[index] ?? false,
onChanged: (value) => setState(() => _selected[index] = value ?? false), onChanged: (value) => setState(() => _selected[index] = value ?? false),
title: Text(item.rawName), title: Row(
subtitle: Text([ children: [
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!, Expanded(child: Text(item.rawName)),
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}', _buildOfferBadge(item, theme),
].join(' · ')), ],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'),
if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()),
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
],
),
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
); );
}), }),
@@ -194,3 +297,4 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
); );
} }
} }